castle-her 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
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