her5 0.8.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 +4 -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 +101 -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/gemfiles/Gemfile.activemodel-5.0.x +7 -0
  17. data/her5.gemspec +30 -0
  18. data/lib/her.rb +19 -0
  19. data/lib/her/api.rb +120 -0
  20. data/lib/her/collection.rb +12 -0
  21. data/lib/her/errors.rb +104 -0
  22. data/lib/her/json_api/model.rb +57 -0
  23. data/lib/her/middleware.rb +12 -0
  24. data/lib/her/middleware/accept_json.rb +17 -0
  25. data/lib/her/middleware/first_level_parse_json.rb +36 -0
  26. data/lib/her/middleware/json_api_parser.rb +68 -0
  27. data/lib/her/middleware/parse_json.rb +28 -0
  28. data/lib/her/middleware/second_level_parse_json.rb +36 -0
  29. data/lib/her/model.rb +75 -0
  30. data/lib/her/model/associations.rb +141 -0
  31. data/lib/her/model/associations/association.rb +107 -0
  32. data/lib/her/model/associations/association_proxy.rb +45 -0
  33. data/lib/her/model/associations/belongs_to_association.rb +101 -0
  34. data/lib/her/model/associations/has_many_association.rb +101 -0
  35. data/lib/her/model/associations/has_one_association.rb +80 -0
  36. data/lib/her/model/attributes.rb +297 -0
  37. data/lib/her/model/base.rb +33 -0
  38. data/lib/her/model/deprecated_methods.rb +61 -0
  39. data/lib/her/model/http.rb +113 -0
  40. data/lib/her/model/introspection.rb +65 -0
  41. data/lib/her/model/nested_attributes.rb +84 -0
  42. data/lib/her/model/orm.rb +207 -0
  43. data/lib/her/model/parse.rb +221 -0
  44. data/lib/her/model/paths.rb +126 -0
  45. data/lib/her/model/relation.rb +164 -0
  46. data/lib/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 +305 -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 +289 -0
@@ -0,0 +1,33 @@
1
+ module Her
2
+ module Model
3
+ # This module includes basic functionnality to Her::Model
4
+ module Base
5
+ extend ActiveSupport::Concern
6
+
7
+ # Returns true if attribute_name is
8
+ # * in resource attributes
9
+ # * an association
10
+ #
11
+ # @private
12
+ def has_key?(attribute_name)
13
+ has_attribute?(attribute_name) ||
14
+ has_association?(attribute_name)
15
+ end
16
+
17
+ # Returns
18
+ # * the value of the attribute_name attribute if it's in orm data
19
+ # * the resource/collection corrsponding to attribute_name if it's an association
20
+ #
21
+ # @private
22
+ def [](attribute_name)
23
+ get_attribute(attribute_name) ||
24
+ get_association(attribute_name)
25
+ end
26
+
27
+ # @private
28
+ def singularized_resource_name
29
+ self.class.name.split('::').last.tableize.singularize
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,61 @@
1
+ module Her
2
+ module Model
3
+ # @private
4
+ module DeprecatedMethods
5
+ extend ActiveSupport::Concern
6
+
7
+ def self.deprecate!(old, new, object, *args)
8
+ line = begin
9
+ raise StandardError
10
+ rescue StandardError => e
11
+ e.backtrace[2]
12
+ end
13
+
14
+ warn "#{line} - The `#{old}` method is deprecated and may be removed soon. Please update your code with `#{new}` instead."
15
+ object.send(new, *args)
16
+ end
17
+
18
+ def data(*args)
19
+ Her::Model::DeprecatedMethods.deprecate! :data, :attributes, self, *args
20
+ end
21
+
22
+ def data=(*args)
23
+ Her::Model::DeprecatedMethods.deprecate! :data=, :attributes=, self, *args
24
+ end
25
+
26
+ def update_attributes(*args)
27
+ Her::Model::DeprecatedMethods.deprecate! :update_attributes, :assign_attributes, self, *args
28
+ end
29
+
30
+ def assign_data(*args)
31
+ Her::Model::DeprecatedMethods.deprecate! :assign_data, :assign_attributes, self, *args
32
+ end
33
+
34
+ def has_data?(*args)
35
+ Her::Model::DeprecatedMethods.deprecate! :has_data?, :has_attribute?, self, *args
36
+ end
37
+
38
+ def get_data(*args)
39
+ Her::Model::DeprecatedMethods.deprecate! :get_data, :get_attribute, self, *args
40
+ end
41
+
42
+ module ClassMethods
43
+ def has_relationship?(*args)
44
+ Her::Model::DeprecatedMethods.deprecate! :has_relationship?, :has_association?, self, *args
45
+ end
46
+
47
+ def get_relationship(*args)
48
+ Her::Model::DeprecatedMethods.deprecate! :get_relationship, :get_association, self, *args
49
+ end
50
+
51
+ def relationships(*args)
52
+ Her::Model::DeprecatedMethods.deprecate! :relationships, :associations, self, *args
53
+ end
54
+
55
+ def her_api(*args)
56
+ Her::Model::DeprecatedMethods.deprecate! :her_api, :use_api, self, *args
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,113 @@
1
+ module Her
2
+ module Model
3
+ # This module interacts with Her::API to fetch HTTP data
4
+ module HTTP
5
+ extend ActiveSupport::Concern
6
+ METHODS = [:get, :post, :put, :patch, :delete]
7
+
8
+ # For each HTTP method, define these class methods:
9
+ #
10
+ # - <method>(path, params)
11
+ # - <method>_raw(path, params, &block)
12
+ # - <method>_collection(path, params, &block)
13
+ # - <method>_resource(path, params, &block)
14
+ # - custom_<method>(*paths)
15
+ #
16
+ # @example
17
+ # class User
18
+ # include Her::Model
19
+ # custom_get :active
20
+ # end
21
+ #
22
+ # User.get(:popular) # GET "/users/popular"
23
+ # User.active # GET "/users/active"
24
+ module ClassMethods
25
+ # Change which API the model will use to make its HTTP requests
26
+ #
27
+ # @example
28
+ # secondary_api = Her::API.new :url => "https://api.example" do |connection|
29
+ # connection.use Faraday::Request::UrlEncoded
30
+ # connection.use Her::Middleware::DefaultParseJSON
31
+ # end
32
+ #
33
+ # class User
34
+ # include Her::Model
35
+ # use_api secondary_api
36
+ # end
37
+ def use_api(value = nil)
38
+ @_her_use_api ||= begin
39
+ superclass.use_api if superclass.respond_to?(:use_api)
40
+ end
41
+
42
+ unless value
43
+ return (@_her_use_api.respond_to? :call) ? @_her_use_api.call : @_her_use_api
44
+ end
45
+
46
+ @_her_use_api = value
47
+ end
48
+
49
+ alias her_api use_api
50
+ alias uses_api use_api
51
+
52
+ # Main request wrapper around Her::API. Used to make custom request to the API.
53
+ #
54
+ # @private
55
+ def request(params={})
56
+ request = her_api.request(params)
57
+ if block_given?
58
+ yield request[:parsed_data], request[:response]
59
+ else
60
+ { :parsed_data => request[:parsed_data], :response => request[:response] }
61
+ end
62
+ end
63
+
64
+ METHODS.each do |method|
65
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
66
+ def #{method}(path, params={})
67
+ path = build_request_path_from_string_or_symbol(path, params)
68
+ params = to_params(params) unless #{method.to_sym.inspect} == :get
69
+ send(:'#{method}_raw', path, params) do |parsed_data, response|
70
+ if parsed_data[:data].is_a?(Array) || active_model_serializers_format? || json_api_format?
71
+ new_collection(parsed_data)
72
+ else
73
+ new(parse(parsed_data[:data]).merge :_metadata => parsed_data[:metadata], :_errors => parsed_data[:errors])
74
+ end
75
+ end
76
+ end
77
+
78
+ def #{method}_raw(path, params={}, &block)
79
+ path = build_request_path_from_string_or_symbol(path, params)
80
+ request(params.merge(:_method => #{method.to_sym.inspect}, :_path => path), &block)
81
+ end
82
+
83
+ def #{method}_collection(path, params={})
84
+ path = build_request_path_from_string_or_symbol(path, params)
85
+ send(:'#{method}_raw', build_request_path_from_string_or_symbol(path, params), params) do |parsed_data, response|
86
+ new_collection(parsed_data)
87
+ end
88
+ end
89
+
90
+ def #{method}_resource(path, params={})
91
+ path = build_request_path_from_string_or_symbol(path, params)
92
+ send(:"#{method}_raw", path, params) do |parsed_data, response|
93
+ new(parse(parsed_data[:data]).merge :_metadata => parsed_data[:metadata], :_errors => parsed_data[:errors])
94
+ end
95
+ end
96
+
97
+ def custom_#{method}(*paths)
98
+ metaclass = (class << self; self; end)
99
+ opts = paths.last.is_a?(Hash) ? paths.pop : Hash.new
100
+
101
+ paths.each do |path|
102
+ metaclass.send(:define_method, path) do |*params|
103
+ params = params.first || Hash.new
104
+ send(#{method.to_sym.inspect}, path, params)
105
+ end
106
+ end
107
+ end
108
+ RUBY
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,65 @@
1
+ module Her
2
+ module Model
3
+ module Introspection
4
+ extend ActiveSupport::Concern
5
+ # Inspect an element, returns it for introspection.
6
+ #
7
+ # @example
8
+ # class User
9
+ # include Her::Model
10
+ # end
11
+ #
12
+ # @user = User.find(1)
13
+ # p @user # => #<User(/users/1) id=1 name="Tobias Fünke">
14
+ def inspect
15
+ resource_path = begin
16
+ request_path
17
+ rescue Her::Errors::PathError => e
18
+ "<unknown path, missing `#{e.missing_parameter}`>"
19
+ end
20
+
21
+ "#<#{self.class}(#{resource_path}) #{attributes.keys.map { |k| "#{k}=#{attribute_for_inspect(send(k))}" }.join(" ")}>"
22
+ end
23
+
24
+ private
25
+ def attribute_for_inspect(value)
26
+ if value.is_a?(String) && value.length > 50
27
+ "#{value[0..50]}...".inspect
28
+ elsif value.is_a?(Date) || value.is_a?(Time)
29
+ %("#{value}")
30
+ else
31
+ value.inspect
32
+ end
33
+ end
34
+
35
+ # @private
36
+ module ClassMethods
37
+ # Finds a class at the same level as this one or at the global level.
38
+ #
39
+ # @private
40
+ def her_nearby_class(name)
41
+ her_sibling_class(name) || name.constantize rescue nil
42
+ end
43
+
44
+ protected
45
+ # Looks for a class at the same level as this one with the given name.
46
+ #
47
+ # @private
48
+ def her_sibling_class(name)
49
+ if mod = self.her_containing_module
50
+ @_her_sibling_class ||= Hash.new { Hash.new }
51
+ @_her_sibling_class[mod][name] ||= "#{mod.name}::#{name}".constantize rescue nil
52
+ end
53
+ end
54
+
55
+ # If available, returns the containing Module for this class.
56
+ #
57
+ # @private
58
+ def her_containing_module
59
+ return unless self.name =~ /::/
60
+ self.name.split("::")[0..-2].join("::").constantize
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,84 @@
1
+ module Her
2
+ module Model
3
+ module NestedAttributes
4
+ extend ActiveSupport::Concern
5
+
6
+ def saved_nested_attributes
7
+ nested_attributes = self.class.saved_nested_associations.each_with_object({}) do |association_name, hash|
8
+ if association = self.send(association_name)
9
+ if association.kind_of?(Array)
10
+ associates = {}
11
+ association.each_with_index {|a, i|
12
+ associates[i] = to_params_for_nesting(a)
13
+ }
14
+ hash["#{association_name}_attributes".to_sym] = associates
15
+ else
16
+ hash["#{association_name}_attributes".to_sym] = to_params_for_nesting(association)
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ def to_params_for_nesting(associate)
23
+ associate_params = associate.to_params
24
+ associate_params = associate_params[associate.class.included_root_element] if associate.class.include_root_in_json?
25
+ associate_params['_destroy'] = associate.destroying?
26
+ associate_params['id'] = associate.id
27
+ associate_params
28
+ end
29
+
30
+
31
+ module ClassMethods
32
+ # Allow nested attributes for an association
33
+ #
34
+ # @example
35
+ # class User
36
+ # include Her::Model
37
+ #
38
+ # has_one :role
39
+ # accepts_nested_attributes_for :role
40
+ # end
41
+ #
42
+ # class Role
43
+ # include Her::Model
44
+ # end
45
+ #
46
+ # user = User.new(name: "Tobias", role_attributes: { title: "moderator" })
47
+ # user.role # => #<Role title="moderator">
48
+ def accepts_nested_attributes_for(*associations)
49
+ allowed_association_names = association_names
50
+
51
+ associations.each do |association_name|
52
+ unless allowed_association_names.include?(association_name)
53
+ raise Her::Errors::AssociationUnknownError.new("Unknown association name :#{association_name} in accepts_nested_attributes_for")
54
+ end
55
+
56
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
57
+ if method_defined?(:#{association_name}_attributes=)
58
+ remove_method(:#{association_name}_attributes=)
59
+ end
60
+
61
+ def #{association_name}_attributes=(attributes)
62
+ self.#{association_name}.assign_nested_attributes(attributes)
63
+ end
64
+ RUBY
65
+ end
66
+ end
67
+
68
+ def saved_nested_associations
69
+ @_her_saved_associations ||= []
70
+ end
71
+
72
+ def sends_nested_attributes_for(*associations)
73
+ allowed_association_names = association_names
74
+ associations.each do |association_name|
75
+ unless allowed_association_names.include?(association_name)
76
+ raise Her::Errors::AssociationUnknownError.new("Unknown association name :#{association_name} in sends_nested_attributes_for")
77
+ end
78
+ saved_nested_associations.push association_name
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,207 @@
1
+ # coding: utf-8
2
+ module Her
3
+ module Model
4
+ # This module adds ORM-like capabilities to the model
5
+ module ORM
6
+ extend ActiveSupport::Concern
7
+
8
+ # Return `true` if a resource was not saved yet
9
+ def new?
10
+ id.nil?
11
+ end
12
+ alias new_record? new?
13
+
14
+ # Return `true` if a resource is not `#new?`
15
+ def persisted?
16
+ !new?
17
+ end
18
+
19
+ # Return whether the object has been destroyed
20
+ def destroyed?
21
+ @destroyed == true
22
+ end
23
+
24
+ # Save a resource and return `false` if the response is not a successful one or
25
+ # if there are errors in the resource. Otherwise, return the newly updated resource
26
+ #
27
+ # @example Save a resource after fetching it
28
+ # @user = User.find(1)
29
+ # # Fetched via GET "/users/1"
30
+ # @user.fullname = "Tobias Fünke"
31
+ # @user.save
32
+ # # Called via PUT "/users/1"
33
+ #
34
+ # @example Save a new resource by creating it
35
+ # @user = User.new({ :fullname => "Tobias Fünke" })
36
+ # @user.save
37
+ # # Called via POST "/users"
38
+ def save
39
+ callback = new? ? :create : :update
40
+ method = self.class.method_for(callback)
41
+
42
+ run_callbacks callback do
43
+ run_callbacks :save do
44
+ params = to_params.merge(:_method => method, :_path => request_path)
45
+ self.class.request(params) do |parsed_data, response|
46
+ assign_attributes(self.class.parse(parsed_data[:data])) if parsed_data[:data].any?
47
+ @metadata = parsed_data[:metadata]
48
+ @response_errors = parsed_data[:errors]
49
+
50
+ return false if !response.success? || @response_errors.any?
51
+ if self.changed_attributes.present?
52
+ @previously_changed = self.changed_attributes.clone
53
+ self.changed_attributes.clear
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ self
60
+ end
61
+
62
+ # Similar to save(), except that ResourceInvalid is raised if the save fails
63
+ def save!
64
+ if !self.save
65
+ raise Her::Errors::ResourceInvalid, self
66
+ end
67
+ self
68
+ end
69
+
70
+ # Destroy a resource
71
+ #
72
+ # @example
73
+ # @user = User.find(1)
74
+ # @user.destroy
75
+ # # Called via DELETE "/users/1"
76
+ def destroy(params = {})
77
+ method = self.class.method_for(:destroy)
78
+ run_callbacks :destroy do
79
+ self.class.request(params.merge(:_method => method, :_path => request_path)) do |parsed_data, response|
80
+ assign_attributes(self.class.parse(parsed_data[:data])) if parsed_data[:data].any?
81
+ @metadata = parsed_data[:metadata]
82
+ @response_errors = parsed_data[:errors]
83
+ @destroyed = true
84
+ end
85
+ end
86
+ self
87
+ end
88
+
89
+ module ClassMethods
90
+ # Create a new chainable scope
91
+ #
92
+ # @example
93
+ # class User
94
+ # include Her::Model
95
+ #
96
+ # scope :admins, lambda { where(:admin => 1) }
97
+ # scope :page, lambda { |page| where(:page => page) }
98
+ # enc
99
+ #
100
+ # User.admins # Called via GET "/users?admin=1"
101
+ # User.page(2).all # Called via GET "/users?page=2"
102
+ def scope(name, code)
103
+ # Add the scope method to the class
104
+ (class << self; self end).send(:define_method, name) do |*args|
105
+ instance_exec(*args, &code)
106
+ end
107
+
108
+ # Add the scope method to the Relation class
109
+ Relation.instance_eval do
110
+ define_method(name) { |*args| instance_exec(*args, &code) }
111
+ end
112
+ end
113
+
114
+ # @private
115
+ def scoped
116
+ @_her_default_scope || blank_relation
117
+ end
118
+
119
+ # Define the default scope for the model
120
+ #
121
+ # @example
122
+ # class User
123
+ # include Her::Model
124
+ #
125
+ # default_scope lambda { where(:admin => 1) }
126
+ # enc
127
+ #
128
+ # User.all # Called via GET "/users?admin=1"
129
+ # User.new.admin # => 1
130
+ def default_scope(block=nil)
131
+ @_her_default_scope ||= (!respond_to?(:default_scope) && superclass.respond_to?(:default_scope)) ? superclass.default_scope : scoped
132
+ @_her_default_scope = @_her_default_scope.instance_exec(&block) unless block.nil?
133
+ @_her_default_scope
134
+ end
135
+
136
+ # Delegate the following methods to `scoped`
137
+ [:all, :where, :create, :build, :find, :first_or_create, :first_or_initialize].each do |method|
138
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
139
+ def #{method}(*params)
140
+ scoped.send(#{method.to_sym.inspect}, *params)
141
+ end
142
+ RUBY
143
+ end
144
+
145
+ # Save an existing resource and return it
146
+ #
147
+ # @example
148
+ # @user = User.save_existing(1, { :fullname => "Tobias Fünke" })
149
+ # # Called via PUT "/users/1"
150
+ def save_existing(id, params)
151
+ resource = new(params.merge(primary_key => id))
152
+ resource.save
153
+ resource
154
+ end
155
+
156
+ # Destroy an existing resource
157
+ #
158
+ # @example
159
+ # User.destroy_existing(1)
160
+ # # Called via DELETE "/users/1"
161
+ def destroy_existing(id, params={})
162
+ request(params.merge(:_method => method_for(:destroy), :_path => build_request_path(params.merge(primary_key => id)))) do |parsed_data, response|
163
+ new(parse(parsed_data[:data]).merge(:_destroyed => true))
164
+ end
165
+ end
166
+
167
+ # Return or change the HTTP method used to create or update records
168
+ #
169
+ # @param [Symbol, String] action The behavior in question (`:create` or `:update`)
170
+ # @param [Symbol, String] method The HTTP method to use (`'PUT'`, `:post`, etc.)
171
+ def method_for(action = nil, method = nil)
172
+ @method_for ||= (superclass.respond_to?(:method_for) ? superclass.method_for : {})
173
+ return @method_for if action.nil?
174
+
175
+ action = action.to_s.downcase.to_sym
176
+
177
+ return @method_for[action] if method.nil?
178
+ @method_for[action] = method.to_s.downcase.to_sym
179
+ end
180
+
181
+ # Build a new resource with the given attributes.
182
+ # If the request_new_object_on_build flag is set, the new object is requested via API.
183
+ def build(attributes = {})
184
+ params = attributes
185
+ return self.new(params) unless self.request_new_object_on_build?
186
+
187
+ path = self.build_request_path(params.merge(self.primary_key => 'new'))
188
+ method = self.method_for(:new)
189
+
190
+ resource = nil
191
+ self.request(params.merge(:_method => method, :_path => path)) do |parsed_data, response|
192
+ if response.success?
193
+ resource = self.new_from_parsed_data(parsed_data)
194
+ end
195
+ end
196
+ resource
197
+ end
198
+
199
+ private
200
+ # @private
201
+ def blank_relation
202
+ @blank_relation ||= Relation.new(self)
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end