herr 0.7.3

Sign up to get free protection for your applications and to get access to all the features.
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