herr 0.7.3

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 (68) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +4 -0
  3. data/.rspec +1 -0
  4. data/.travis.yml +15 -0
  5. data/.yardopts +2 -0
  6. data/CONTRIBUTING.md +26 -0
  7. data/Gemfile +10 -0
  8. data/LICENSE +7 -0
  9. data/README.md +990 -0
  10. data/Rakefile +11 -0
  11. data/UPGRADE.md +81 -0
  12. data/gemfiles/Gemfile.activemodel-3.2.x +7 -0
  13. data/gemfiles/Gemfile.activemodel-4.0 +7 -0
  14. data/gemfiles/Gemfile.activemodel-4.1 +7 -0
  15. data/gemfiles/Gemfile.activemodel-4.2 +7 -0
  16. data/her.gemspec +30 -0
  17. data/lib/her.rb +16 -0
  18. data/lib/her/api.rb +115 -0
  19. data/lib/her/collection.rb +12 -0
  20. data/lib/her/errors.rb +27 -0
  21. data/lib/her/middleware.rb +10 -0
  22. data/lib/her/middleware/accept_json.rb +17 -0
  23. data/lib/her/middleware/first_level_parse_json.rb +36 -0
  24. data/lib/her/middleware/parse_json.rb +21 -0
  25. data/lib/her/middleware/second_level_parse_json.rb +36 -0
  26. data/lib/her/model.rb +72 -0
  27. data/lib/her/model/associations.rb +141 -0
  28. data/lib/her/model/associations/association.rb +103 -0
  29. data/lib/her/model/associations/association_proxy.rb +46 -0
  30. data/lib/her/model/associations/belongs_to_association.rb +96 -0
  31. data/lib/her/model/associations/has_many_association.rb +100 -0
  32. data/lib/her/model/associations/has_one_association.rb +79 -0
  33. data/lib/her/model/attributes.rb +266 -0
  34. data/lib/her/model/base.rb +33 -0
  35. data/lib/her/model/deprecated_methods.rb +61 -0
  36. data/lib/her/model/http.rb +114 -0
  37. data/lib/her/model/introspection.rb +65 -0
  38. data/lib/her/model/nested_attributes.rb +45 -0
  39. data/lib/her/model/orm.rb +205 -0
  40. data/lib/her/model/parse.rb +227 -0
  41. data/lib/her/model/paths.rb +121 -0
  42. data/lib/her/model/relation.rb +164 -0
  43. data/lib/her/version.rb +3 -0
  44. data/spec/api_spec.rb +131 -0
  45. data/spec/collection_spec.rb +26 -0
  46. data/spec/middleware/accept_json_spec.rb +10 -0
  47. data/spec/middleware/first_level_parse_json_spec.rb +62 -0
  48. data/spec/middleware/second_level_parse_json_spec.rb +35 -0
  49. data/spec/model/associations_spec.rb +416 -0
  50. data/spec/model/attributes_spec.rb +268 -0
  51. data/spec/model/callbacks_spec.rb +145 -0
  52. data/spec/model/dirty_spec.rb +86 -0
  53. data/spec/model/http_spec.rb +194 -0
  54. data/spec/model/introspection_spec.rb +76 -0
  55. data/spec/model/nested_attributes_spec.rb +134 -0
  56. data/spec/model/orm_spec.rb +479 -0
  57. data/spec/model/parse_spec.rb +373 -0
  58. data/spec/model/paths_spec.rb +341 -0
  59. data/spec/model/relation_spec.rb +226 -0
  60. data/spec/model/validations_spec.rb +42 -0
  61. data/spec/model_spec.rb +31 -0
  62. data/spec/spec_helper.rb +26 -0
  63. data/spec/support/extensions/array.rb +5 -0
  64. data/spec/support/extensions/hash.rb +5 -0
  65. data/spec/support/macros/her_macros.rb +17 -0
  66. data/spec/support/macros/model_macros.rb +29 -0
  67. data/spec/support/macros/request_macros.rb +27 -0
  68. metadata +280 -0
@@ -0,0 +1,227 @@
1
+ module Her
2
+ module Model
3
+ # This module handles resource data parsing at the model level (after the parsing middleware)
4
+ module Parse
5
+ extend ActiveSupport::Concern
6
+
7
+ # Convert into a hash of request parameters, based on `include_root_in_json`.
8
+ #
9
+ # @example
10
+ # @user.to_params
11
+ # # => { :id => 1, :name => 'John Smith' }
12
+ def to_params
13
+ self.class.to_params(self.attributes, self.changes)
14
+ end
15
+
16
+ module ClassMethods
17
+ # Parse data before assigning it to a resource, based on `parse_root_in_json`.
18
+ #
19
+ # @param [Hash] data
20
+ # @private
21
+ def parse(data)
22
+ if parse_root_in_json? && root_element_included?(data)
23
+ if json_api_format?
24
+
25
+ parse_json_api_format(data, parsed_root_element)
26
+ else
27
+ data.fetch(parsed_root_element) { data }
28
+ end
29
+ else
30
+ data
31
+ end
32
+ end
33
+
34
+ # @private
35
+ def to_params(attributes, changes={})
36
+ filtered_attributes = attributes.dup.symbolize_keys
37
+ filtered_attributes.merge!(embeded_params(attributes))
38
+ if her_api.options[:send_only_modified_attributes]
39
+ filtered_attributes = changes.symbolize_keys.keys.inject({}) do |hash, attribute|
40
+ hash[attribute] = filtered_attributes[attribute]
41
+ hash
42
+ end
43
+ end
44
+
45
+ if include_root_in_json?
46
+ if json_api_format?
47
+ { included_root_element => [filtered_attributes] }
48
+ else
49
+ { included_root_element => filtered_attributes }
50
+ end
51
+ else
52
+ filtered_attributes
53
+ end
54
+ end
55
+
56
+ # @private
57
+ def parse_json_api_format(data, root_element)
58
+ element = data.fetch(root_element)
59
+ if element.is_a?(Array)
60
+ element.first
61
+ else
62
+ element
63
+ end
64
+ end
65
+
66
+
67
+ # @private
68
+ # TODO: Handle has_one
69
+ def embeded_params(attributes)
70
+ associations[:has_many].select { |a| attributes.include?(a[:data_key])}.compact.inject({}) do |hash, association|
71
+ params = attributes[association[:data_key]].map(&:to_params)
72
+ next if params.empty?
73
+ if association[:class_name].constantize.include_root_in_json?
74
+ root = association[:class_name].constantize.root_element
75
+ hash[association[:data_key]] = params.map { |n| n[root] }
76
+ else
77
+ hash[association[:data_key]] = params
78
+ end
79
+ hash
80
+ end
81
+ end
82
+
83
+ # Return or change the value of `include_root_in_json`
84
+ #
85
+ # @example
86
+ # class User
87
+ # include Her::Model
88
+ # include_root_in_json true
89
+ # end
90
+ def include_root_in_json(value, options = {})
91
+ @_her_include_root_in_json = value
92
+ @_her_include_root_in_json_format = options[:format]
93
+ end
94
+
95
+ # Return or change the value of `parse_root_in_json`
96
+ #
97
+ # @example
98
+ # class User
99
+ # include Her::Model
100
+ # parse_root_in_json true
101
+ # end
102
+ #
103
+ # class User
104
+ # include Her::Model
105
+ # parse_root_in_json true, format: :active_model_serializers
106
+ # end
107
+ #
108
+ # class User
109
+ # include Her::Model
110
+ # parse_root_in_json true, format: :json_api
111
+ # end
112
+ def parse_root_in_json(value, options = {})
113
+ @_her_parse_root_in_json = value
114
+ @_her_parse_root_in_json_format = options[:format]
115
+ end
116
+
117
+ # Return or change the value of `request_new_object_on_build`
118
+ #
119
+ # @example
120
+ # class User
121
+ # include Her::Model
122
+ # request_new_object_on_build true
123
+ # end
124
+ def request_new_object_on_build(value = nil)
125
+ @_her_request_new_object_on_build = value
126
+ end
127
+
128
+ # Return or change the value of `root_element`. Always defaults to the base name of the class.
129
+ #
130
+ # @example
131
+ # class User
132
+ # include Her::Model
133
+ # parse_root_in_json true
134
+ # root_element :huh
135
+ # end
136
+ #
137
+ # user = User.find(1) # { :huh => { :id => 1, :name => "Tobias" } }
138
+ # user.name # => "Tobias"
139
+ def root_element(value = nil)
140
+ if value.nil?
141
+ if json_api_format?
142
+ @_her_root_element ||= self.name.split("::").last.pluralize.underscore.to_sym
143
+ else
144
+ @_her_root_element ||= self.name.split("::").last.underscore.to_sym
145
+ end
146
+ else
147
+ @_her_root_element = value.to_sym
148
+ end
149
+ end
150
+
151
+ # @private
152
+ def root_element_included?(data)
153
+ data.keys.to_s.include? @_her_root_element.to_s
154
+ end
155
+
156
+ # @private
157
+ def included_root_element
158
+ include_root_in_json? == true ? root_element : include_root_in_json?
159
+ end
160
+
161
+ # Extract an array from the request data
162
+ #
163
+ # @example
164
+ # # with parse_root_in_json true, :format => :active_model_serializers
165
+ # class User
166
+ # include Her::Model
167
+ # parse_root_in_json true, :format => :active_model_serializers
168
+ # end
169
+ #
170
+ # users = User.all # { :users => [ { :id => 1, :name => "Tobias" } ] }
171
+ # users.first.name # => "Tobias"
172
+ #
173
+ # # without parse_root_in_json
174
+ # class User
175
+ # include Her::Model
176
+ # end
177
+ #
178
+ # users = User.all # [ { :id => 1, :name => "Tobias" } ]
179
+ # users.first.name # => "Tobias"
180
+ #
181
+ # @private
182
+ def extract_array(request_data)
183
+ if active_model_serializers_format? || json_api_format?
184
+ request_data[:data][pluralized_parsed_root_element]
185
+ else
186
+ request_data[:data]
187
+ end
188
+ end
189
+
190
+ # @private
191
+ def pluralized_parsed_root_element
192
+ parsed_root_element.to_s.pluralize.to_sym
193
+ end
194
+
195
+ # @private
196
+ def parsed_root_element
197
+ parse_root_in_json? == true ? root_element : parse_root_in_json?
198
+ end
199
+
200
+ # @private
201
+ def active_model_serializers_format?
202
+ @_her_parse_root_in_json_format == :active_model_serializers || (superclass.respond_to?(:active_model_serializers_format?) && superclass.active_model_serializers_format?)
203
+ end
204
+
205
+ # @private
206
+ def json_api_format?
207
+ @_her_parse_root_in_json_format == :json_api || (superclass.respond_to?(:json_api_format?) && superclass.json_api_format?)
208
+ end
209
+
210
+ # @private
211
+ def request_new_object_on_build?
212
+ @_her_request_new_object_on_build || (superclass.respond_to?(:request_new_object_on_build?) && superclass.request_new_object_on_build?)
213
+ end
214
+
215
+ # @private
216
+ def include_root_in_json?
217
+ @_her_include_root_in_json || (superclass.respond_to?(:include_root_in_json?) && superclass.include_root_in_json?)
218
+ end
219
+
220
+ # @private
221
+ def parse_root_in_json?
222
+ @_her_parse_root_in_json || (superclass.respond_to?(:parse_root_in_json?) && superclass.parse_root_in_json?)
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,121 @@
1
+ module Her
2
+ module Model
3
+ module Paths
4
+ extend ActiveSupport::Concern
5
+ # Return a path based on the collection path and a resource data
6
+ #
7
+ # @example
8
+ # class User
9
+ # include Her::Model
10
+ # collection_path "/utilisateurs"
11
+ # end
12
+ #
13
+ # User.find(1) # Fetched via GET /utilisateurs/1
14
+ #
15
+ # @param [Hash] params An optional set of additional parameters for
16
+ # path construction. These will not override attributes of the resource.
17
+ def request_path(params = {})
18
+ self.class.build_request_path(params.merge(attributes.dup))
19
+ end
20
+
21
+ module ClassMethods
22
+
23
+ # Define the primary key field that will be used to find and save records
24
+ #
25
+ # @example
26
+ # class User
27
+ # include Her::Model
28
+ # primary_key 'UserId'
29
+ # end
30
+ #
31
+ # @param [Symbol] value
32
+ def primary_key(value = nil)
33
+ @_her_primary_key ||= begin
34
+ superclass.primary_key if superclass.respond_to?(:primary_key)
35
+ end
36
+
37
+ return @_her_primary_key unless value
38
+ @_her_primary_key = value.to_sym
39
+ end
40
+
41
+ # Defines a custom collection path for the resource
42
+ #
43
+ # @example
44
+ # class User
45
+ # include Her::Model
46
+ # collection_path "/users"
47
+ # end
48
+ def collection_path(path = nil)
49
+ if path.nil?
50
+ @_her_collection_path ||= root_element.to_s.pluralize
51
+ else
52
+ @_her_collection_path = path
53
+ @_her_resource_path = "#{path}/:id"
54
+ end
55
+ end
56
+
57
+ # Defines a custom resource path for the resource
58
+ #
59
+ # @example
60
+ # class User
61
+ # include Her::Model
62
+ # resource_path "/users/:id"
63
+ # end
64
+ #
65
+ # Note that, if used in combination with resource_path, you may specify
66
+ # either the real primary key or the string ':id'. For example:
67
+ #
68
+ # @example
69
+ # class User
70
+ # include Her::Model
71
+ # primary_key 'user_id'
72
+ #
73
+ # # This works because we'll have a user_id attribute
74
+ # resource_path '/users/:user_id'
75
+ #
76
+ # # This works because we replace :id with :user_id
77
+ # resource_path '/users/:id'
78
+ # end
79
+ #
80
+ def resource_path(path = nil)
81
+ if path.nil?
82
+ @_her_resource_path ||= "#{root_element.to_s.pluralize}/:id"
83
+ else
84
+ @_her_resource_path = path
85
+ end
86
+ end
87
+
88
+ # Return a custom path based on the collection path and variable parameters
89
+ #
90
+ # @private
91
+ def build_request_path(path=nil, parameters={})
92
+ parameters = parameters.try(:with_indifferent_access)
93
+
94
+ unless path.is_a?(String)
95
+ parameters = path.try(:with_indifferent_access) || parameters
96
+ path =
97
+ if parameters.include?(primary_key) && parameters[primary_key] && !parameters[primary_key].kind_of?(Array)
98
+ resource_path.dup
99
+ else
100
+ collection_path.dup
101
+ end
102
+
103
+ # Replace :id with our actual primary key
104
+ path.gsub!(/(\A|\/):id(\Z|\/)/, "\\1:#{primary_key}\\2")
105
+ end
106
+
107
+ path.gsub(/:([\w_]+)/) do
108
+ # Look for :key or :_key, otherwise raise an exception
109
+ value = $1.to_sym
110
+ parameters.delete(value) || parameters.delete(:"_#{value}") || raise(Her::Errors::PathError.new("Missing :_#{$1} parameter to build the request path. Path is `#{path}`. Parameters are `#{parameters.symbolize_keys.inspect}`.", $1))
111
+ end
112
+ end
113
+
114
+ # @private
115
+ def build_request_path_from_string_or_symbol(path, params={})
116
+ path.is_a?(Symbol) ? "#{build_request_path(params)}/#{path}" : path
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,164 @@
1
+ module Her
2
+ module Model
3
+ class Relation
4
+ # @private
5
+ attr_accessor :params
6
+
7
+ # @private
8
+ def initialize(parent)
9
+ @parent = parent
10
+ @params = {}
11
+ end
12
+
13
+ # @private
14
+ def apply_to(attributes)
15
+ @params.merge(attributes)
16
+ end
17
+
18
+ # Build a new resource
19
+ def build(attributes = {})
20
+ @parent.build(@params.merge(attributes))
21
+ end
22
+
23
+ # Add a query string parameter
24
+ #
25
+ # @example
26
+ # @users = User.all
27
+ # # Fetched via GET "/users"
28
+ #
29
+ # @example
30
+ # @users = User.where(:approved => 1).all
31
+ # # Fetched via GET "/users?approved=1"
32
+ def where(params = {})
33
+ return self if params.blank? && !@_fetch.nil?
34
+ self.clone.tap do |r|
35
+ r.params = r.params.merge(params)
36
+ r.clear_fetch_cache!
37
+ end
38
+ end
39
+ alias all where
40
+
41
+ # Bubble all methods to the fetched collection
42
+ #
43
+ # @private
44
+ def method_missing(method, *args, &blk)
45
+ fetch.send(method, *args, &blk)
46
+ end
47
+
48
+ # @private
49
+ def respond_to?(method, *args)
50
+ super || fetch.respond_to?(method, *args)
51
+ end
52
+
53
+ # @private
54
+ def nil?
55
+ fetch.nil?
56
+ end
57
+
58
+ # @private
59
+ def kind_of?(thing)
60
+ fetch.kind_of?(thing)
61
+ end
62
+
63
+ # Fetch a collection of resources
64
+ #
65
+ # @private
66
+ def fetch
67
+ @_fetch ||= begin
68
+ path = @parent.build_request_path(@params)
69
+ method = @parent.method_for(:find)
70
+ @parent.request(@params.merge(:_method => method, :_path => path)) do |parsed_data, response|
71
+ @parent.new_collection(parsed_data)
72
+ end
73
+ end
74
+ end
75
+
76
+ # Fetch specific resource(s) by their ID
77
+ #
78
+ # @example
79
+ # @user = User.find(1)
80
+ # # Fetched via GET "/users/1"
81
+ #
82
+ # @example
83
+ # @users = User.find([1, 2])
84
+ # # Fetched via GET "/users/1" and GET "/users/2"
85
+ def find(*ids)
86
+ params = @params.merge(ids.last.is_a?(Hash) ? ids.pop : {})
87
+ ids = Array(params[@parent.primary_key]) if params.key?(@parent.primary_key)
88
+
89
+ results = ids.flatten.compact.uniq.map do |id|
90
+ resource = nil
91
+ request_params = params.merge(
92
+ :_method => @parent.method_for(:find),
93
+ :_path => @parent.build_request_path(params.merge(@parent.primary_key => id))
94
+ )
95
+
96
+ @parent.request(request_params) do |parsed_data, response|
97
+ if response.success?
98
+ resource = @parent.new_from_parsed_data(parsed_data)
99
+ resource.instance_variable_set(:@changed_attributes, {})
100
+ resource.run_callbacks :find
101
+ else
102
+ return nil
103
+ end
104
+ end
105
+
106
+ resource
107
+ end
108
+
109
+ ids.length > 1 || ids.first.kind_of?(Array) ? results : results.first
110
+ end
111
+
112
+ # Create a resource and return it
113
+ #
114
+ # @example
115
+ # @user = User.create(:fullname => "Tobias Fünke")
116
+ # # Called via POST "/users/1" with `&fullname=Tobias+Fünke`
117
+ #
118
+ # @example
119
+ # @user = User.where(:email => "tobias@bluth.com").create(:fullname => "Tobias Fünke")
120
+ # # Called via POST "/users/1" with `&email=tobias@bluth.com&fullname=Tobias+Fünke`
121
+ def create(attributes = {})
122
+ attributes ||= {}
123
+ resource = @parent.new(@params.merge(attributes))
124
+ resource.save
125
+
126
+ resource
127
+ end
128
+
129
+ # Fetch a resource and create it if it's not found
130
+ #
131
+ # @example
132
+ # @user = User.where(:email => "remi@example.com").find_or_create
133
+ #
134
+ # # Returns the first item of the collection if present:
135
+ # # GET "/users?email=remi@example.com"
136
+ #
137
+ # # If collection is empty:
138
+ # # POST /users with `email=remi@example.com`
139
+ def first_or_create(attributes = {})
140
+ fetch.first || create(attributes)
141
+ end
142
+
143
+ # Fetch a resource and build it if it's not found
144
+ #
145
+ # @example
146
+ # @user = User.where(:email => "remi@example.com").find_or_initialize
147
+ #
148
+ # # Returns the first item of the collection if present:
149
+ # # GET "/users?email=remi@example.com"
150
+ #
151
+ # # If collection is empty:
152
+ # @user.email # => "remi@example.com"
153
+ # @user.new? # => true
154
+ def first_or_initialize(attributes = {})
155
+ fetch.first || build(attributes)
156
+ end
157
+
158
+ # @private
159
+ def clear_fetch_cache!
160
+ instance_variable_set(:@_fetch, nil)
161
+ end
162
+ end
163
+ end
164
+ end