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.
- checksums.yaml +7 -0
- data/.gitignore +4 -0
- data/.rspec +1 -0
- data/.travis.yml +15 -0
- data/.yardopts +2 -0
- data/CONTRIBUTING.md +26 -0
- data/Gemfile +10 -0
- data/LICENSE +7 -0
- data/README.md +990 -0
- data/Rakefile +11 -0
- data/UPGRADE.md +81 -0
- data/gemfiles/Gemfile.activemodel-3.2.x +7 -0
- data/gemfiles/Gemfile.activemodel-4.0 +7 -0
- data/gemfiles/Gemfile.activemodel-4.1 +7 -0
- data/gemfiles/Gemfile.activemodel-4.2 +7 -0
- data/her.gemspec +30 -0
- data/lib/her.rb +16 -0
- data/lib/her/api.rb +115 -0
- data/lib/her/collection.rb +12 -0
- data/lib/her/errors.rb +27 -0
- data/lib/her/middleware.rb +10 -0
- data/lib/her/middleware/accept_json.rb +17 -0
- data/lib/her/middleware/first_level_parse_json.rb +36 -0
- data/lib/her/middleware/parse_json.rb +21 -0
- data/lib/her/middleware/second_level_parse_json.rb +36 -0
- data/lib/her/model.rb +72 -0
- data/lib/her/model/associations.rb +141 -0
- data/lib/her/model/associations/association.rb +103 -0
- data/lib/her/model/associations/association_proxy.rb +46 -0
- data/lib/her/model/associations/belongs_to_association.rb +96 -0
- data/lib/her/model/associations/has_many_association.rb +100 -0
- data/lib/her/model/associations/has_one_association.rb +79 -0
- data/lib/her/model/attributes.rb +266 -0
- data/lib/her/model/base.rb +33 -0
- data/lib/her/model/deprecated_methods.rb +61 -0
- data/lib/her/model/http.rb +114 -0
- data/lib/her/model/introspection.rb +65 -0
- data/lib/her/model/nested_attributes.rb +45 -0
- data/lib/her/model/orm.rb +205 -0
- data/lib/her/model/parse.rb +227 -0
- data/lib/her/model/paths.rb +121 -0
- data/lib/her/model/relation.rb +164 -0
- data/lib/her/version.rb +3 -0
- data/spec/api_spec.rb +131 -0
- data/spec/collection_spec.rb +26 -0
- data/spec/middleware/accept_json_spec.rb +10 -0
- data/spec/middleware/first_level_parse_json_spec.rb +62 -0
- data/spec/middleware/second_level_parse_json_spec.rb +35 -0
- data/spec/model/associations_spec.rb +416 -0
- data/spec/model/attributes_spec.rb +268 -0
- data/spec/model/callbacks_spec.rb +145 -0
- data/spec/model/dirty_spec.rb +86 -0
- data/spec/model/http_spec.rb +194 -0
- data/spec/model/introspection_spec.rb +76 -0
- data/spec/model/nested_attributes_spec.rb +134 -0
- data/spec/model/orm_spec.rb +479 -0
- data/spec/model/parse_spec.rb +373 -0
- data/spec/model/paths_spec.rb +341 -0
- data/spec/model/relation_spec.rb +226 -0
- data/spec/model/validations_spec.rb +42 -0
- data/spec/model_spec.rb +31 -0
- data/spec/spec_helper.rb +26 -0
- data/spec/support/extensions/array.rb +5 -0
- data/spec/support/extensions/hash.rb +5 -0
- data/spec/support/macros/her_macros.rb +17 -0
- data/spec/support/macros/model_macros.rb +29 -0
- data/spec/support/macros/request_macros.rb +27 -0
- metadata +280 -0
data/lib/her/model.rb
ADDED
@@ -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
|