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,72 @@
1
+ require "her/model/base"
2
+ require "her/model/deprecated_methods"
3
+ require "her/model/http"
4
+ require "her/model/attributes"
5
+ require "her/model/relation"
6
+ require "her/model/orm"
7
+ require "her/model/parse"
8
+ require "her/model/associations"
9
+ require "her/model/introspection"
10
+ require "her/model/paths"
11
+ require "her/model/nested_attributes"
12
+ require "active_model"
13
+
14
+ module Her
15
+ # This module is the main element of Her. After creating a Her::API object,
16
+ # include this module in your models to get a few magic methods defined in them.
17
+ #
18
+ # @example
19
+ # class User
20
+ # include Her::Model
21
+ # end
22
+ #
23
+ # @user = User.new(:name => "Rémi")
24
+ # @user.save
25
+ module Model
26
+ extend ActiveSupport::Concern
27
+
28
+ # Her modules
29
+ include Her::Model::Base
30
+ include Her::Model::DeprecatedMethods
31
+ include Her::Model::Attributes
32
+ include Her::Model::ORM
33
+ include Her::Model::HTTP
34
+ include Her::Model::Parse
35
+ include Her::Model::Introspection
36
+ include Her::Model::Paths
37
+ include Her::Model::Associations
38
+ include Her::Model::NestedAttributes
39
+
40
+ # Supported ActiveModel modules
41
+ include ActiveModel::AttributeMethods
42
+ include ActiveModel::Validations
43
+ include ActiveModel::Validations::Callbacks
44
+ include ActiveModel::Conversion
45
+ include ActiveModel::Dirty
46
+
47
+ # Class methods
48
+ included do
49
+ # Assign the default API
50
+ use_api Her::API.default_api
51
+ method_for :create, :post
52
+ method_for :update, :put
53
+ method_for :find, :get
54
+ method_for :destroy, :delete
55
+ method_for :new, :get
56
+
57
+ # Define the default primary key
58
+ primary_key :id
59
+
60
+ # Define default storage accessors for errors and metadata
61
+ store_response_errors :response_errors
62
+ store_metadata :metadata
63
+
64
+ # Include ActiveModel naming methods
65
+ extend ActiveModel::Translation
66
+
67
+ # Configure ActiveModel callbacks
68
+ extend ActiveModel::Callbacks
69
+ define_model_callbacks :create, :update, :save, :find, :destroy, :initialize
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,141 @@
1
+ require "her/model/associations/association"
2
+ require "her/model/associations/association_proxy"
3
+ require "her/model/associations/belongs_to_association"
4
+ require "her/model/associations/has_many_association"
5
+ require "her/model/associations/has_one_association"
6
+
7
+ module Her
8
+ module Model
9
+ # This module adds associations to models
10
+ module Associations
11
+ extend ActiveSupport::Concern
12
+
13
+ # Returns true if the model has a association_name association, false otherwise.
14
+ #
15
+ # @private
16
+ def has_association?(association_name)
17
+ associations = self.class.associations.values.flatten.map { |r| r[:name] }
18
+ associations.include?(association_name)
19
+ end
20
+
21
+ # Returns the resource/collection corresponding to the association_name association.
22
+ #
23
+ # @private
24
+ def get_association(association_name)
25
+ send(association_name) if has_association?(association_name)
26
+ end
27
+
28
+ module ClassMethods
29
+ # Return @_her_associations, lazily initialized with copy of the
30
+ # superclass' her_associations, or an empty hash.
31
+ #
32
+ # @private
33
+ def associations
34
+ @_her_associations ||= begin
35
+ superclass.respond_to?(:associations) ? superclass.associations.dup : Hash.new { |h,k| h[k] = [] }
36
+ end
37
+ end
38
+
39
+ # @private
40
+ def association_names
41
+ associations.inject([]) { |memo, (name, details)| memo << details }.flatten.map { |a| a[:name] }
42
+ end
43
+
44
+ # @private
45
+ def association_keys
46
+ associations.inject([]) { |memo, (name, details)| memo << details }.flatten.map { |a| a[:data_key] }
47
+ end
48
+
49
+ # Parse associations data after initializing a new object
50
+ #
51
+ # @private
52
+ def parse_associations(data)
53
+ associations.each_pair do |type, definitions|
54
+ definitions.each do |association|
55
+ association_class = "her/model/associations/#{type}_association".classify.constantize
56
+ data.merge! association_class.parse(association, self, data)
57
+ end
58
+ end
59
+
60
+ data
61
+ end
62
+
63
+ # Define an *has_many* association.
64
+ #
65
+ # @param [Symbol] name The name of the method added to resources
66
+ # @param [Hash] opts Options
67
+ # @option opts [String] :class_name The name of the class to map objects to
68
+ # @option opts [Symbol] :data_key The attribute where the data is stored
69
+ # @option opts [Path] :path The relative path where to fetch the data (defaults to `/{name}`)
70
+ #
71
+ # @example
72
+ # class User
73
+ # include Her::Model
74
+ # has_many :articles
75
+ # end
76
+ #
77
+ # class Article
78
+ # include Her::Model
79
+ # end
80
+ #
81
+ # @user = User.find(1)
82
+ # @user.articles # => [#<Article(articles/2) id=2 title="Hello world.">]
83
+ # # Fetched via GET "/users/1/articles"
84
+ def has_many(name, opts={})
85
+ Her::Model::Associations::HasManyAssociation.attach(self, name, opts)
86
+ end
87
+
88
+ # Define an *has_one* association.
89
+ #
90
+ # @param [Symbol] name The name of the method added to resources
91
+ # @param [Hash] opts Options
92
+ # @option opts [String] :class_name The name of the class to map objects to
93
+ # @option opts [Symbol] :data_key The attribute where the data is stored
94
+ # @option opts [Path] :path The relative path where to fetch the data (defaults to `/{name}`)
95
+ #
96
+ # @example
97
+ # class User
98
+ # include Her::Model
99
+ # has_one :organization
100
+ # end
101
+ #
102
+ # class Organization
103
+ # include Her::Model
104
+ # end
105
+ #
106
+ # @user = User.find(1)
107
+ # @user.organization # => #<Organization(organizations/2) id=2 name="Foobar Inc.">
108
+ # # Fetched via GET "/users/1/organization"
109
+ def has_one(name, opts={})
110
+ Her::Model::Associations::HasOneAssociation.attach(self, name, opts)
111
+ end
112
+
113
+ # Define a *belongs_to* association.
114
+ #
115
+ # @param [Symbol] name The name of the method added to resources
116
+ # @param [Hash] opts Options
117
+ # @option opts [String] :class_name The name of the class to map objects to
118
+ # @option opts [Symbol] :data_key The attribute where the data is stored
119
+ # @option opts [Path] :path The relative path where to fetch the data (defaults to `/{class_name}.pluralize/{id}`)
120
+ # @option opts [Symbol] :foreign_key The foreign key used to build the `:id` part of the path (defaults to `{name}_id`)
121
+ #
122
+ # @example
123
+ # class User
124
+ # include Her::Model
125
+ # belongs_to :team, :class_name => "Group"
126
+ # end
127
+ #
128
+ # class Group
129
+ # include Her::Model
130
+ # end
131
+ #
132
+ # @user = User.find(1) # => #<User(users/1) id=1 team_id=2 name="Tobias">
133
+ # @user.team # => #<Team(teams/2) id=2 name="Developers">
134
+ # # Fetched via GET "/teams/2"
135
+ def belongs_to(name, opts={})
136
+ Her::Model::Associations::BelongsToAssociation.attach(self, name, opts)
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,103 @@
1
+ module Her
2
+ module Model
3
+ module Associations
4
+ class Association
5
+ # @private
6
+ attr_accessor :params
7
+
8
+ # @private
9
+ def initialize(parent, opts = {})
10
+ @parent = parent
11
+ @opts = opts
12
+ @params = {}
13
+
14
+ @klass = @parent.class.her_nearby_class(@opts[:class_name])
15
+ @name = @opts[:name]
16
+ end
17
+
18
+ # @private
19
+ def self.proxy(parent, opts = {})
20
+ AssociationProxy.new new(parent, opts)
21
+ end
22
+
23
+ # @private
24
+ def self.parse_single(association, klass, data)
25
+ data_key = association[:data_key]
26
+ return {} unless data[data_key]
27
+
28
+ klass = klass.her_nearby_class(association[:class_name])
29
+ if data[data_key].kind_of?(klass)
30
+ { association[:name] => data[data_key] }
31
+ else
32
+ { association[:name] => klass.new(data[data_key]) }
33
+ end
34
+ end
35
+
36
+ # @private
37
+ def assign_single_nested_attributes(attributes)
38
+ if @parent.attributes[@name].blank?
39
+ @parent.attributes[@name] = @klass.new(@klass.parse(attributes))
40
+ else
41
+ @parent.attributes[@name].assign_attributes(attributes)
42
+ end
43
+ end
44
+
45
+ # @private
46
+ def fetch(opts = {})
47
+ attribute_value = @parent.attributes[@name]
48
+ return @opts[:default].try(:dup) if @parent.attributes.include?(@name) && (attribute_value.nil? || !attribute_value.nil? && attribute_value.empty?) && @params.empty?
49
+
50
+ return @cached_result unless @params.any? || @cached_result.nil?
51
+ return @parent.attributes[@name] unless @params.any? || @parent.attributes[@name].blank?
52
+
53
+ path = build_association_path lambda { "#{@parent.request_path(@params)}#{@opts[:path]}" }
54
+ @klass.get(path, @params).tap do |result|
55
+ @cached_result = result unless @params.any?
56
+ end
57
+ end
58
+
59
+ # @private
60
+ def build_association_path(code)
61
+ begin
62
+ instance_exec(&code)
63
+ rescue Her::Errors::PathError
64
+ return nil
65
+ end
66
+ end
67
+
68
+ # Add query parameters to the HTTP request performed to fetch the data
69
+ #
70
+ # @example
71
+ # class User
72
+ # include Her::Model
73
+ # has_many :comments
74
+ # end
75
+ #
76
+ # user = User.find(1)
77
+ # user.comments.where(:approved => 1) # Fetched via GET "/users/1/comments?approved=1
78
+ def where(params = {})
79
+ return self if params.blank? && @parent.attributes[@name].blank?
80
+ AssociationProxy.new self.clone.tap { |a| a.params = a.params.merge(params) }
81
+ end
82
+ alias all where
83
+
84
+ # Fetches the data specified by id
85
+ #
86
+ # @example
87
+ # class User
88
+ # include Her::Model
89
+ # has_many :comments
90
+ # end
91
+ #
92
+ # user = User.find(1)
93
+ # user.comments.find(3) # Fetched via GET "/users/1/comments/3
94
+ def find(id)
95
+ return nil if id.blank?
96
+ path = build_association_path lambda { "#{@parent.request_path(@params)}#{@opts[:path]}/#{id}" }
97
+ @klass.get(path, @params)
98
+ end
99
+
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,46 @@
1
+ module Her
2
+ module Model
3
+ module Associations
4
+ class AssociationProxy < (ActiveSupport.const_defined?('ProxyObject') ? ActiveSupport::ProxyObject : ActiveSupport::BasicObject)
5
+
6
+ # @private
7
+ def self.install_proxy_methods(target_name, *names)
8
+ names.each do |name|
9
+ module_eval <<-RUBY, __FILE__, __LINE__ + 1
10
+ def #{name}(*args, &block)
11
+ #{target_name}.#{name}(*args, &block)
12
+ end
13
+ RUBY
14
+ end
15
+ end
16
+
17
+ install_proxy_methods :association,
18
+ :build, :create, :where, :find, :all, :assign_nested_attributes
19
+
20
+ # @private
21
+ def initialize(association)
22
+ @_her_association = association
23
+ end
24
+
25
+ def association
26
+ @_her_association
27
+ end
28
+
29
+ # @private
30
+ def method_missing(name, *args, &block)
31
+ if :object_id == name # avoid redefining object_id
32
+ return association.fetch.object_id
33
+ end
34
+
35
+ # create a proxy to the fetched object's method
36
+ metaclass = (class << self; self; end)
37
+ metaclass.install_proxy_methods 'association.fetch', name
38
+
39
+ # resend message to fetched object
40
+ __send__(name, *args, &block)
41
+ end
42
+
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,96 @@
1
+ module Her
2
+ module Model
3
+ module Associations
4
+ class BelongsToAssociation < Association
5
+
6
+ # @private
7
+ def self.attach(klass, name, opts)
8
+ opts = {
9
+ :class_name => name.to_s.classify,
10
+ :name => name,
11
+ :data_key => name,
12
+ :default => nil,
13
+ :foreign_key => "#{name}_id",
14
+ :path => "/#{name.to_s.pluralize}/:id"
15
+ }.merge(opts)
16
+ klass.associations[:belongs_to] << opts
17
+
18
+ klass.class_eval <<-RUBY, __FILE__, __LINE__ + 1
19
+ def #{name}
20
+ cached_name = :"@_her_association_#{name}"
21
+
22
+ cached_data = (instance_variable_defined?(cached_name) && instance_variable_get(cached_name))
23
+ cached_data || instance_variable_set(cached_name, Her::Model::Associations::BelongsToAssociation.proxy(self, #{opts.inspect}))
24
+ end
25
+ RUBY
26
+ end
27
+
28
+ # @private
29
+ def self.parse(*args)
30
+ parse_single(*args)
31
+ end
32
+
33
+ # Initialize a new object
34
+ #
35
+ # @example
36
+ # class User
37
+ # include Her::Model
38
+ # belongs_to :organization
39
+ # end
40
+ #
41
+ # class Organization
42
+ # include Her::Model
43
+ # end
44
+ #
45
+ # user = User.find(1)
46
+ # new_organization = user.organization.build(:name => "Foo Inc.")
47
+ # new_organization # => #<Organization name="Foo Inc.">
48
+ def build(attributes = {})
49
+ @klass.build(attributes)
50
+ end
51
+
52
+ # Create a new object, save it and associate it to the parent
53
+ #
54
+ # @example
55
+ # class User
56
+ # include Her::Model
57
+ # belongs_to :organization
58
+ # end
59
+ #
60
+ # class Organization
61
+ # include Her::Model
62
+ # end
63
+ #
64
+ # user = User.find(1)
65
+ # user.organization.create(:name => "Foo Inc.")
66
+ # user.organization # => #<Organization id=2 name="Foo Inc.">
67
+ def create(attributes = {})
68
+ resource = build(attributes)
69
+ @parent.attributes[@name] = resource if resource.save
70
+ resource
71
+ end
72
+
73
+ # @private
74
+ def fetch
75
+ foreign_key_value = @parent.attributes[@opts[:foreign_key].to_sym]
76
+ data_key_value = @parent.attributes[@opts[:data_key].to_sym]
77
+ return @opts[:default].try(:dup) if (@parent.attributes.include?(@name) && @parent.attributes[@name].nil? && @params.empty?) || (@parent.persisted? && foreign_key_value.blank? && data_key_value.blank?)
78
+
79
+ return @cached_result unless @params.any? || @cached_result.nil?
80
+ return @parent.attributes[@name] unless @params.any? || @parent.attributes[@name].blank?
81
+
82
+ path_params = @parent.attributes.merge(@params.merge(@klass.primary_key => foreign_key_value))
83
+ path = build_association_path lambda { @klass.build_request_path(path_params) }
84
+ @klass.get(path, @params).tap do |result|
85
+ @cached_result = result if @params.blank?
86
+ end
87
+ end
88
+
89
+ # @private
90
+ def assign_nested_attributes(attributes)
91
+ assign_single_nested_attributes(attributes)
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end