castle-her 1.0.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 +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,36 @@
1
+ module Her
2
+ module Middleware
3
+ # This middleware treat the received first-level JSON structure as the resource data.
4
+ class FirstLevelParseJSON < ParseJSON
5
+ # Parse the response body
6
+ #
7
+ # @param [String] body The response body
8
+ # @return [Mixed] the parsed response
9
+ # @private
10
+ def parse(body)
11
+ json = parse_json(body)
12
+ errors = json.delete(:errors) || {}
13
+ metadata = json.delete(:metadata) || {}
14
+ {
15
+ :data => json,
16
+ :errors => errors,
17
+ :metadata => metadata
18
+ }
19
+ end
20
+
21
+ # This method is triggered when the response has been received. It modifies
22
+ # the value of `env[:body]`.
23
+ #
24
+ # @param [Hash] env The response environment
25
+ # @private
26
+ def on_complete(env)
27
+ env[:body] = case env[:status]
28
+ when 204
29
+ parse('{}')
30
+ else
31
+ parse(env[:body])
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,36 @@
1
+ module Her
2
+ module Middleware
3
+ # This middleware expects the resource/collection data to be contained in the `data`
4
+ # key of the JSON object
5
+ class JsonApiParser < ParseJSON
6
+ # Parse the response body
7
+ #
8
+ # @param [String] body The response body
9
+ # @return [Mixed] the parsed response
10
+ # @private
11
+ def parse(body)
12
+ json = parse_json(body)
13
+
14
+ {
15
+ :data => json[:data] || {},
16
+ :errors => json[:errors] || [],
17
+ :metadata => json[:meta] || {},
18
+ }
19
+ end
20
+
21
+ # This method is triggered when the response has been received. It modifies
22
+ # the value of `env[:body]`.
23
+ #
24
+ # @param [Hash] env The response environment
25
+ # @private
26
+ def on_complete(env)
27
+ env[:body] = case env[:status]
28
+ when 204
29
+ parse('{}')
30
+ else
31
+ parse(env[:body])
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,21 @@
1
+ module Her
2
+ module Middleware
3
+ class ParseJSON < Faraday::Response::Middleware
4
+ # @private
5
+ def parse_json(body = nil)
6
+ body = '{}' if body.blank?
7
+ message = "Response from the API must behave like a Hash or an Array (last JSON response was #{body.inspect})"
8
+
9
+ json = begin
10
+ MultiJson.load(body, :symbolize_keys => true)
11
+ rescue MultiJson::LoadError
12
+ raise Her::Errors::ParseError, message
13
+ end
14
+
15
+ raise Her::Errors::ParseError, message unless json.is_a?(Hash) or json.is_a?(Array)
16
+
17
+ json
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,36 @@
1
+ module Her
2
+ module Middleware
3
+ # This middleware expects the resource/collection data to be contained in the `data`
4
+ # key of the JSON object
5
+ class SecondLevelParseJSON < ParseJSON
6
+ # Parse the response body
7
+ #
8
+ # @param [String] body The response body
9
+ # @return [Mixed] the parsed response
10
+ # @private
11
+ def parse(body)
12
+ json = parse_json(body)
13
+
14
+ {
15
+ :data => json[:data],
16
+ :errors => json[:errors],
17
+ :metadata => json[:metadata]
18
+ }
19
+ end
20
+
21
+ # This method is triggered when the response has been received. It modifies
22
+ # the value of `env[:body]`.
23
+ #
24
+ # @param [Hash] env The response environment
25
+ # @private
26
+ def on_complete(env)
27
+ env[:body] = case env[:status]
28
+ when 204
29
+ parse('{}')
30
+ else
31
+ parse(env[:body])
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,75 @@
1
+ require "castle-her/model/base"
2
+ require "castle-her/model/deprecated_methods"
3
+ require "castle-her/model/http"
4
+ require "castle-her/model/attributes"
5
+ require "castle-her/model/relation"
6
+ require "castle-her/model/orm"
7
+ require "castle-her/model/parse"
8
+ require "castle-her/model/associations"
9
+ require "castle-her/model/introspection"
10
+ require "castle-her/model/paths"
11
+ require "castle-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
+
71
+ # Define matchers for attr? and attr= methods
72
+ define_attribute_method_matchers
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,141 @@
1
+ require "castle-her/model/associations/association"
2
+ require "castle-her/model/associations/association_proxy"
3
+ require "castle-her/model/associations/belongs_to_association"
4
+ require "castle-her/model/associations/has_many_association"
5
+ require "castle-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(klass.parse(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_resource(path, @params)
98
+ end
99
+
100
+ end
101
+ end
102
+ end
103
+ end