castle-her 1.0.1

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 (74) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +6 -0
  3. data/.rspec +1 -0
  4. data/.travis.yml +17 -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 +1017 -0
  10. data/Rakefile +11 -0
  11. data/UPGRADE.md +110 -0
  12. data/castle-her.gemspec +30 -0
  13. data/gemfiles/Gemfile.activemodel-3.2.x +7 -0
  14. data/gemfiles/Gemfile.activemodel-4.0 +7 -0
  15. data/gemfiles/Gemfile.activemodel-4.1 +7 -0
  16. data/gemfiles/Gemfile.activemodel-4.2 +7 -0
  17. data/gemfiles/Gemfile.activemodel-5.0.x +7 -0
  18. data/lib/castle-her.rb +20 -0
  19. data/lib/castle-her/api.rb +113 -0
  20. data/lib/castle-her/collection.rb +12 -0
  21. data/lib/castle-her/errors.rb +27 -0
  22. data/lib/castle-her/json_api/model.rb +46 -0
  23. data/lib/castle-her/middleware.rb +12 -0
  24. data/lib/castle-her/middleware/accept_json.rb +17 -0
  25. data/lib/castle-her/middleware/first_level_parse_json.rb +36 -0
  26. data/lib/castle-her/middleware/json_api_parser.rb +36 -0
  27. data/lib/castle-her/middleware/parse_json.rb +21 -0
  28. data/lib/castle-her/middleware/second_level_parse_json.rb +36 -0
  29. data/lib/castle-her/model.rb +75 -0
  30. data/lib/castle-her/model/associations.rb +141 -0
  31. data/lib/castle-her/model/associations/association.rb +103 -0
  32. data/lib/castle-her/model/associations/association_proxy.rb +45 -0
  33. data/lib/castle-her/model/associations/belongs_to_association.rb +96 -0
  34. data/lib/castle-her/model/associations/has_many_association.rb +100 -0
  35. data/lib/castle-her/model/associations/has_one_association.rb +79 -0
  36. data/lib/castle-her/model/attributes.rb +284 -0
  37. data/lib/castle-her/model/base.rb +33 -0
  38. data/lib/castle-her/model/deprecated_methods.rb +61 -0
  39. data/lib/castle-her/model/http.rb +114 -0
  40. data/lib/castle-her/model/introspection.rb +65 -0
  41. data/lib/castle-her/model/nested_attributes.rb +45 -0
  42. data/lib/castle-her/model/orm.rb +207 -0
  43. data/lib/castle-her/model/parse.rb +216 -0
  44. data/lib/castle-her/model/paths.rb +126 -0
  45. data/lib/castle-her/model/relation.rb +164 -0
  46. data/lib/castle-her/version.rb +3 -0
  47. data/spec/api_spec.rb +114 -0
  48. data/spec/collection_spec.rb +26 -0
  49. data/spec/json_api/model_spec.rb +166 -0
  50. data/spec/middleware/accept_json_spec.rb +10 -0
  51. data/spec/middleware/first_level_parse_json_spec.rb +62 -0
  52. data/spec/middleware/json_api_parser_spec.rb +32 -0
  53. data/spec/middleware/second_level_parse_json_spec.rb +35 -0
  54. data/spec/model/associations/association_proxy_spec.rb +31 -0
  55. data/spec/model/associations_spec.rb +504 -0
  56. data/spec/model/attributes_spec.rb +389 -0
  57. data/spec/model/callbacks_spec.rb +145 -0
  58. data/spec/model/dirty_spec.rb +91 -0
  59. data/spec/model/http_spec.rb +158 -0
  60. data/spec/model/introspection_spec.rb +76 -0
  61. data/spec/model/nested_attributes_spec.rb +134 -0
  62. data/spec/model/orm_spec.rb +506 -0
  63. data/spec/model/parse_spec.rb +345 -0
  64. data/spec/model/paths_spec.rb +347 -0
  65. data/spec/model/relation_spec.rb +226 -0
  66. data/spec/model/validations_spec.rb +42 -0
  67. data/spec/model_spec.rb +44 -0
  68. data/spec/spec_helper.rb +26 -0
  69. data/spec/support/extensions/array.rb +5 -0
  70. data/spec/support/extensions/hash.rb +5 -0
  71. data/spec/support/macros/her_macros.rb +17 -0
  72. data/spec/support/macros/model_macros.rb +36 -0
  73. data/spec/support/macros/request_macros.rb +27 -0
  74. metadata +290 -0
@@ -0,0 +1,216 @@
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
+ data.fetch(parsed_root_element).first
25
+ else
26
+ data.fetch(parsed_root_element) { data }
27
+ end
28
+ else
29
+ data
30
+ end
31
+ end
32
+
33
+ # @private
34
+ def to_params(attributes, changes={})
35
+ filtered_attributes = attributes.dup.symbolize_keys
36
+ filtered_attributes.merge!(embeded_params(attributes))
37
+ if her_api.options[:send_only_modified_attributes]
38
+ filtered_attributes = changes.symbolize_keys.keys.inject({}) do |hash, attribute|
39
+ hash[attribute] = filtered_attributes[attribute]
40
+ hash
41
+ end
42
+ end
43
+
44
+ if include_root_in_json?
45
+ if json_api_format?
46
+ { included_root_element => [filtered_attributes] }
47
+ else
48
+ { included_root_element => filtered_attributes }
49
+ end
50
+ else
51
+ filtered_attributes
52
+ end
53
+ end
54
+
55
+
56
+ # @private
57
+ # TODO: Handle has_one
58
+ def embeded_params(attributes)
59
+ associations[:has_many].select { |a| attributes.include?(a[:data_key])}.compact.inject({}) do |hash, association|
60
+ params = attributes[association[:data_key]].map(&:to_params)
61
+ next if params.empty?
62
+ if association[:class_name].constantize.include_root_in_json?
63
+ root = association[:class_name].constantize.root_element
64
+ hash[association[:data_key]] = params.map { |n| n[root] }
65
+ else
66
+ hash[association[:data_key]] = params
67
+ end
68
+ hash
69
+ end
70
+ end
71
+
72
+ # Return or change the value of `include_root_in_json`
73
+ #
74
+ # @example
75
+ # class User
76
+ # include Her::Model
77
+ # include_root_in_json true
78
+ # end
79
+ def include_root_in_json(value, options = {})
80
+ @_her_include_root_in_json = value
81
+ @_her_include_root_in_json_format = options[:format]
82
+ end
83
+
84
+ # Return or change the value of `parse_root_in_json`
85
+ #
86
+ # @example
87
+ # class User
88
+ # include Her::Model
89
+ # parse_root_in_json true
90
+ # end
91
+ #
92
+ # class User
93
+ # include Her::Model
94
+ # parse_root_in_json true, format: :active_model_serializers
95
+ # end
96
+ #
97
+ # class User
98
+ # include Her::Model
99
+ # parse_root_in_json true, format: :json_api
100
+ # end
101
+ def parse_root_in_json(value, options = {})
102
+ @_her_parse_root_in_json = value
103
+ @_her_parse_root_in_json_format = options[:format]
104
+ end
105
+
106
+ # Return or change the value of `request_new_object_on_build`
107
+ #
108
+ # @example
109
+ # class User
110
+ # include Her::Model
111
+ # request_new_object_on_build true
112
+ # end
113
+ def request_new_object_on_build(value = nil)
114
+ @_her_request_new_object_on_build = value
115
+ end
116
+
117
+ # Return or change the value of `root_element`. Always defaults to the base name of the class.
118
+ #
119
+ # @example
120
+ # class User
121
+ # include Her::Model
122
+ # parse_root_in_json true
123
+ # root_element :huh
124
+ # end
125
+ #
126
+ # user = User.find(1) # { :huh => { :id => 1, :name => "Tobias" } }
127
+ # user.name # => "Tobias"
128
+ def root_element(value = nil)
129
+ if value.nil?
130
+ if json_api_format?
131
+ @_her_root_element ||= self.name.split("::").last.pluralize.underscore.to_sym
132
+ else
133
+ @_her_root_element ||= self.name.split("::").last.underscore.to_sym
134
+ end
135
+ else
136
+ @_her_root_element = value.to_sym
137
+ end
138
+ end
139
+
140
+ # @private
141
+ def root_element_included?(data)
142
+ data.keys.to_s.include? @_her_root_element.to_s
143
+ end
144
+
145
+ # @private
146
+ def included_root_element
147
+ include_root_in_json? == true ? root_element : include_root_in_json?
148
+ end
149
+
150
+ # Extract an array from the request data
151
+ #
152
+ # @example
153
+ # # with parse_root_in_json true, :format => :active_model_serializers
154
+ # class User
155
+ # include Her::Model
156
+ # parse_root_in_json true, :format => :active_model_serializers
157
+ # end
158
+ #
159
+ # users = User.all # { :users => [ { :id => 1, :name => "Tobias" } ] }
160
+ # users.first.name # => "Tobias"
161
+ #
162
+ # # without parse_root_in_json
163
+ # class User
164
+ # include Her::Model
165
+ # end
166
+ #
167
+ # users = User.all # [ { :id => 1, :name => "Tobias" } ]
168
+ # users.first.name # => "Tobias"
169
+ #
170
+ # @private
171
+ def extract_array(request_data)
172
+ if request_data[:data].is_a?(Hash) && (active_model_serializers_format? || json_api_format?)
173
+ request_data[:data][pluralized_parsed_root_element]
174
+ else
175
+ request_data[:data]
176
+ end
177
+ end
178
+
179
+ # @private
180
+ def pluralized_parsed_root_element
181
+ parsed_root_element.to_s.pluralize.to_sym
182
+ end
183
+
184
+ # @private
185
+ def parsed_root_element
186
+ parse_root_in_json? == true ? root_element : parse_root_in_json?
187
+ end
188
+
189
+ # @private
190
+ def active_model_serializers_format?
191
+ @_her_parse_root_in_json_format == :active_model_serializers || (superclass.respond_to?(:active_model_serializers_format?) && superclass.active_model_serializers_format?)
192
+ end
193
+
194
+ # @private
195
+ def json_api_format?
196
+ @_her_parse_root_in_json_format == :json_api || (superclass.respond_to?(:json_api_format?) && superclass.json_api_format?)
197
+ end
198
+
199
+ # @private
200
+ def request_new_object_on_build?
201
+ @_her_request_new_object_on_build || (superclass.respond_to?(:request_new_object_on_build?) && superclass.request_new_object_on_build?)
202
+ end
203
+
204
+ # @private
205
+ def include_root_in_json?
206
+ @_her_include_root_in_json || (superclass.respond_to?(:include_root_in_json?) && superclass.include_root_in_json?)
207
+ end
208
+
209
+ # @private
210
+ def parse_root_in_json?
211
+ @_her_parse_root_in_json || (superclass.respond_to?(:parse_root_in_json?) && superclass.parse_root_in_json?)
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,126 @@
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
+ key = $1.to_sym
110
+ value = parameters.delete(key) || parameters.delete(:"_#{key}")
111
+ if value
112
+ Faraday::Utils.escape value
113
+ else
114
+ raise(Her::Errors::PathError.new("Missing :_#{$1} parameter to build the request path. Path is `#{path}`. Parameters are `#{parameters.symbolize_keys.inspect}`.", $1))
115
+ end
116
+ end
117
+ end
118
+
119
+ # @private
120
+ def build_request_path_from_string_or_symbol(path, params={})
121
+ path.is_a?(Symbol) ? "#{build_request_path(params)}/#{path}" : path
122
+ end
123
+ end
124
+ end
125
+ end
126
+ 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