castle-her 1.0.1

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