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,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,114 @@
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
+
58
+ if block_given?
59
+ yield request[:parsed_data], request[:response]
60
+ else
61
+ { :parsed_data => request[:parsed_data], :response => request[:response] }
62
+ end
63
+ end
64
+
65
+ METHODS.each do |method|
66
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
67
+ def #{method}(path, params={})
68
+ path = build_request_path_from_string_or_symbol(path, params)
69
+ params = to_params(params) unless #{method.to_sym.inspect} == :get
70
+ send(:'#{method}_raw', path, params) do |parsed_data, response|
71
+ if parsed_data[:data].is_a?(Array) || active_model_serializers_format? || json_api_format?
72
+ new_collection(parsed_data)
73
+ else
74
+ new(parse(parsed_data[:data]).merge :_metadata => parsed_data[:metadata], :_errors => parsed_data[:errors])
75
+ end
76
+ end
77
+ end
78
+
79
+ def #{method}_raw(path, params={}, &block)
80
+ path = build_request_path_from_string_or_symbol(path, params)
81
+ request(params.merge(:_method => #{method.to_sym.inspect}, :_path => path), &block)
82
+ end
83
+
84
+ def #{method}_collection(path, params={})
85
+ path = build_request_path_from_string_or_symbol(path, params)
86
+ send(:'#{method}_raw', build_request_path_from_string_or_symbol(path, params), params) do |parsed_data, response|
87
+ new_collection(parsed_data)
88
+ end
89
+ end
90
+
91
+ def #{method}_resource(path, params={})
92
+ path = build_request_path_from_string_or_symbol(path, params)
93
+ send(:"#{method}_raw", path, params) do |parsed_data, response|
94
+ new(parse(parsed_data[:data]).merge :_metadata => parsed_data[:metadata], :_errors => parsed_data[:errors])
95
+ end
96
+ end
97
+
98
+ def custom_#{method}(*paths)
99
+ metaclass = (class << self; self; end)
100
+ opts = paths.last.is_a?(Hash) ? paths.pop : Hash.new
101
+
102
+ paths.each do |path|
103
+ metaclass.send(:define_method, path) do |*params|
104
+ params = params.first || Hash.new
105
+ send(#{method.to_sym.inspect}, path, params)
106
+ end
107
+ end
108
+ end
109
+ RUBY
110
+ end
111
+ end
112
+ end
113
+ end
114
+ 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,45 @@
1
+ module Her
2
+ module Model
3
+ module NestedAttributes
4
+ extend ActiveSupport::Concern
5
+
6
+ module ClassMethods
7
+ # Allow nested attributes for an association
8
+ #
9
+ # @example
10
+ # class User
11
+ # include Her::Model
12
+ #
13
+ # has_one :role
14
+ # accepts_nested_attributes_for :role
15
+ # end
16
+ #
17
+ # class Role
18
+ # include Her::Model
19
+ # end
20
+ #
21
+ # user = User.new(name: "Tobias", role_attributes: { title: "moderator" })
22
+ # user.role # => #<Role title="moderator">
23
+ def accepts_nested_attributes_for(*associations)
24
+ allowed_association_names = association_names
25
+
26
+ associations.each do |association_name|
27
+ unless allowed_association_names.include?(association_name)
28
+ raise Her::Errors::AssociationUnknownError.new("Unknown association name :#{association_name}")
29
+ end
30
+
31
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
32
+ if method_defined?(:#{association_name}_attributes=)
33
+ remove_method(:#{association_name}_attributes=)
34
+ end
35
+
36
+ def #{association_name}_attributes=(attributes)
37
+ self.#{association_name}.assign_nested_attributes(attributes)
38
+ end
39
+ RUBY
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,205 @@
1
+ module Her
2
+ module Model
3
+ # This module adds ORM-like capabilities to the model
4
+ module ORM
5
+ extend ActiveSupport::Concern
6
+
7
+ # Return `true` if a resource was not saved yet
8
+ def new?
9
+ id.nil?
10
+ end
11
+
12
+ # Return `true` if a resource is not `#new?`
13
+ def persisted?
14
+ !new?
15
+ end
16
+
17
+ # Return whether the object has been destroyed
18
+ def destroyed?
19
+ @destroyed == true
20
+ end
21
+
22
+ # Save a resource and return `false` if the response is not a successful one or
23
+ # if there are errors in the resource. Otherwise, return the newly updated resource
24
+ #
25
+ # @example Save a resource after fetching it
26
+ # @user = User.find(1)
27
+ # # Fetched via GET "/users/1"
28
+ # @user.fullname = "Tobias Fünke"
29
+ # @user.save
30
+ # # Called via PUT "/users/1"
31
+ #
32
+ # @example Save a new resource by creating it
33
+ # @user = User.new({ :fullname => "Tobias Fünke" })
34
+ # @user.save
35
+ # # Called via POST "/users"
36
+ def save
37
+ callback = new? ? :create : :update
38
+ method = self.class.method_for(callback)
39
+
40
+ run_callbacks callback do
41
+ run_callbacks :save do
42
+ params = to_params
43
+ self.class.request(to_params.merge(:_method => method, :_path => request_path)) do |parsed_data, response|
44
+ assign_attributes(self.class.parse(parsed_data[:data])) if parsed_data[:data].any?
45
+ @metadata = parsed_data[:metadata]
46
+ @response_errors = parsed_data[:errors]
47
+
48
+ return false if !response.success? || @response_errors.any?
49
+ if self.changed_attributes.present?
50
+ @previously_changed = self.changed_attributes.clone
51
+ self.changed_attributes.clear
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ self
58
+ end
59
+
60
+ # Similar to save(), except that ResourceInvalid is raised if the save fails
61
+ def save!
62
+ if !self.save
63
+ raise Her::Errors::ResourceInvalid, self
64
+ end
65
+ self
66
+ end
67
+
68
+ # Destroy a resource
69
+ #
70
+ # @example
71
+ # @user = User.find(1)
72
+ # @user.destroy
73
+ # # Called via DELETE "/users/1"
74
+ def destroy
75
+ method = self.class.method_for(:destroy)
76
+ run_callbacks :destroy do
77
+ self.class.request(:_method => method, :_path => request_path) do |parsed_data, response|
78
+ assign_attributes(self.class.parse(parsed_data[:data])) if parsed_data[:data].any?
79
+ @metadata = parsed_data[:metadata]
80
+ @response_errors = parsed_data[:errors]
81
+ @destroyed = true
82
+ end
83
+ end
84
+ self
85
+ end
86
+
87
+ module ClassMethods
88
+ # Create a new chainable scope
89
+ #
90
+ # @example
91
+ # class User
92
+ # include Her::Model
93
+ #
94
+ # scope :admins, lambda { where(:admin => 1) }
95
+ # scope :page, lambda { |page| where(:page => page) }
96
+ # enc
97
+ #
98
+ # User.admins # Called via GET "/users?admin=1"
99
+ # User.page(2).all # Called via GET "/users?page=2"
100
+ def scope(name, code)
101
+ # Add the scope method to the class
102
+ (class << self; self end).send(:define_method, name) do |*args|
103
+ instance_exec(*args, &code)
104
+ end
105
+
106
+ # Add the scope method to the Relation class
107
+ Relation.instance_eval do
108
+ define_method(name) { |*args| instance_exec(*args, &code) }
109
+ end
110
+ end
111
+
112
+ # @private
113
+ def scoped
114
+ @_her_default_scope || blank_relation
115
+ end
116
+
117
+ # Define the default scope for the model
118
+ #
119
+ # @example
120
+ # class User
121
+ # include Her::Model
122
+ #
123
+ # default_scope lambda { where(:admin => 1) }
124
+ # enc
125
+ #
126
+ # User.all # Called via GET "/users?admin=1"
127
+ # User.new.admin # => 1
128
+ def default_scope(block=nil)
129
+ @_her_default_scope ||= (!respond_to?(:default_scope) && superclass.respond_to?(:default_scope)) ? superclass.default_scope : scoped
130
+ @_her_default_scope = @_her_default_scope.instance_exec(&block) unless block.nil?
131
+ @_her_default_scope
132
+ end
133
+
134
+ # Delegate the following methods to `scoped`
135
+ [:all, :where, :create, :build, :find, :first_or_create, :first_or_initialize].each do |method|
136
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
137
+ def #{method}(*params)
138
+ scoped.send(#{method.to_sym.inspect}, *params)
139
+ end
140
+ RUBY
141
+ end
142
+
143
+ # Save an existing resource and return it
144
+ #
145
+ # @example
146
+ # @user = User.save_existing(1, { :fullname => "Tobias Fünke" })
147
+ # # Called via PUT "/users/1"
148
+ def save_existing(id, params)
149
+ resource = new(params.merge(primary_key => id))
150
+ resource.save
151
+ resource
152
+ end
153
+
154
+ # Destroy an existing resource
155
+ #
156
+ # @example
157
+ # User.destroy_existing(1)
158
+ # # Called via DELETE "/users/1"
159
+ def destroy_existing(id, params={})
160
+ request(params.merge(:_method => method_for(:destroy), :_path => build_request_path(params.merge(primary_key => id)))) do |parsed_data, response|
161
+ new(parse(parsed_data[:data]).merge(:_destroyed => true))
162
+ end
163
+ end
164
+
165
+ # Return or change the HTTP method used to create or update records
166
+ #
167
+ # @param [Symbol, String] action The behavior in question (`:create` or `:update`)
168
+ # @param [Symbol, String] method The HTTP method to use (`'PUT'`, `:post`, etc.)
169
+ def method_for(action = nil, method = nil)
170
+ @method_for ||= (superclass.respond_to?(:method_for) ? superclass.method_for : {})
171
+ return @method_for if action.nil?
172
+
173
+ action = action.to_s.downcase.to_sym
174
+
175
+ return @method_for[action] if method.nil?
176
+ @method_for[action] = method.to_s.downcase.to_sym
177
+ end
178
+
179
+ # Build a new resource with the given attributes.
180
+ # If the request_new_object_on_build flag is set, the new object is requested via API.
181
+ def build(attributes = {})
182
+ params = attributes
183
+ return self.new(params) unless self.request_new_object_on_build?
184
+
185
+ path = self.build_request_path(params.merge(self.primary_key => 'new'))
186
+ method = self.method_for(:new)
187
+
188
+ resource = nil
189
+ self.request(params.merge(:_method => method, :_path => path)) do |parsed_data, response|
190
+ if response.success?
191
+ resource = self.new_from_parsed_data(parsed_data)
192
+ end
193
+ end
194
+ resource
195
+ end
196
+
197
+ private
198
+ # @private
199
+ def blank_relation
200
+ @blank_relation ||= Relation.new(self)
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end