her5 0.8.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 +4 -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 +101 -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/gemfiles/Gemfile.activemodel-5.0.x +7 -0
  17. data/her5.gemspec +30 -0
  18. data/lib/her.rb +19 -0
  19. data/lib/her/api.rb +120 -0
  20. data/lib/her/collection.rb +12 -0
  21. data/lib/her/errors.rb +104 -0
  22. data/lib/her/json_api/model.rb +57 -0
  23. data/lib/her/middleware.rb +12 -0
  24. data/lib/her/middleware/accept_json.rb +17 -0
  25. data/lib/her/middleware/first_level_parse_json.rb +36 -0
  26. data/lib/her/middleware/json_api_parser.rb +68 -0
  27. data/lib/her/middleware/parse_json.rb +28 -0
  28. data/lib/her/middleware/second_level_parse_json.rb +36 -0
  29. data/lib/her/model.rb +75 -0
  30. data/lib/her/model/associations.rb +141 -0
  31. data/lib/her/model/associations/association.rb +107 -0
  32. data/lib/her/model/associations/association_proxy.rb +45 -0
  33. data/lib/her/model/associations/belongs_to_association.rb +101 -0
  34. data/lib/her/model/associations/has_many_association.rb +101 -0
  35. data/lib/her/model/associations/has_one_association.rb +80 -0
  36. data/lib/her/model/attributes.rb +297 -0
  37. data/lib/her/model/base.rb +33 -0
  38. data/lib/her/model/deprecated_methods.rb +61 -0
  39. data/lib/her/model/http.rb +113 -0
  40. data/lib/her/model/introspection.rb +65 -0
  41. data/lib/her/model/nested_attributes.rb +84 -0
  42. data/lib/her/model/orm.rb +207 -0
  43. data/lib/her/model/parse.rb +221 -0
  44. data/lib/her/model/paths.rb +126 -0
  45. data/lib/her/model/relation.rb +164 -0
  46. data/lib/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 +305 -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 +289 -0
@@ -0,0 +1,57 @@
1
+ module Her
2
+ module JsonApi
3
+ module Model
4
+
5
+ def self.included(klass)
6
+ klass.class_eval do
7
+ include Her::Model
8
+
9
+ # [:parse_root_in_json, :include_root_in_json, :root_element, :primary_key].each do |method|
10
+ # define_method method do |*args|
11
+ # raise NoMethodError, "Her::JsonApi::Model does not support the #{method} configuration option"
12
+ # end
13
+ # end
14
+
15
+ method_for :update, :patch
16
+
17
+ @type = name.demodulize.tableize
18
+
19
+ def self.parse(data)
20
+ begin
21
+ if data.has_key?(:attributes)
22
+ data.fetch(:attributes).merge(data.slice(:id))
23
+ else
24
+ data
25
+ end
26
+ rescue
27
+ nil
28
+ end
29
+ end
30
+
31
+
32
+ # For now our APIs are able to deliver jsonapi but not to digest it. We reply with standard rails parameters.
33
+ #
34
+ # def self.to_params(attributes, changes={})
35
+ # request_data = { type: @type }.tap { |request_body|
36
+ # attrs = attributes.dup.symbolize_keys.tap { |filtered_attributes|
37
+ # if her_api.options[:send_only_modified_attributes]
38
+ # filtered_attributes = changes.symbolize_keys.keys.inject({}) do |hash, attribute|
39
+ # hash[attribute] = filtered_attributes[attribute]
40
+ # hash
41
+ # end
42
+ # end
43
+ # }
44
+ # request_body[:id] = attrs.delete(:id) if attrs[:id]
45
+ # request_body[:attributes] = attrs
46
+ # }
47
+ # { data: request_data }
48
+ # end
49
+
50
+ def self.type(type_name)
51
+ @type = type_name.to_s
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,12 @@
1
+ require "her/middleware/parse_json"
2
+ require "her/middleware/first_level_parse_json"
3
+ require "her/middleware/second_level_parse_json"
4
+ require "her/middleware/accept_json"
5
+
6
+ module Her
7
+ module Middleware
8
+ DefaultParseJSON = FirstLevelParseJSON
9
+
10
+ autoload :JsonApiParser, 'her/middleware/json_api_parser'
11
+ end
12
+ end
@@ -0,0 +1,17 @@
1
+ module Her
2
+ module Middleware
3
+ # This middleware adds a "Accept: application/json" HTTP header
4
+ class AcceptJSON < Faraday::Middleware
5
+ # @private
6
+ def add_header(headers)
7
+ headers.merge! "Accept" => "application/json"
8
+ end
9
+
10
+ # @private
11
+ def call(env)
12
+ add_header(env[:request_headers])
13
+ @app.call(env)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -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,68 @@
1
+ module Her
2
+ module Middleware
3
+ # This middleware requires the resource/collection
4
+ # data to be contained in the `data` 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
+ included = json.fetch(:included, [])
15
+ primary_data = json.fetch(:data, {})
16
+ resources = Array.wrap(primary_data)
17
+ resources.each do |resource|
18
+ if resource[:attributes]
19
+ resource.fetch(:attributes).merge!(build_relationships(resource, included))
20
+ end
21
+ end
22
+
23
+ {
24
+ :data => primary_data || {},
25
+ :errors => json[:errors] || [],
26
+ :metadata => json[:meta] || {},
27
+ }
28
+ end
29
+
30
+ def build_relationships(resource, included)
31
+ relationships = resource.fetch(:relationships, {})
32
+ {}.tap do |built|
33
+ relationships.each do |rel_name, linkage|
34
+ if linkage_data = linkage.fetch(:data, {})
35
+ built_relationship = if linkage_data.is_a? Array
36
+ linkage_data.map { |l| included.detect { |i| i && i.values_at(:id, :type) == l.values_at(:id, :type) } }
37
+ else
38
+ included.detect { |i| i && i.values_at(:id, :type) == linkage_data.values_at(:id, :type) }
39
+ end
40
+
41
+ built[rel_name] = built_relationship if built_relationship
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ # This method is triggered when the response has been received. It modifies
48
+ # the value of `env[:body]`.
49
+ #
50
+ # @param [Hash] env The response environment
51
+ # @private
52
+ def on_complete(env)
53
+ assert_response_ok(env[:status], env[:body])
54
+ env[:body] = case env[:status]
55
+ when 204
56
+ {
57
+ :data => {},
58
+ :errors => [],
59
+ :metadata => {},
60
+ }
61
+ else
62
+ parse(env[:body])
63
+ end
64
+ end
65
+
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,28 @@
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
+
20
+ def assert_response_ok(status, message)
21
+ if exception_class = Her::Errors.exception_class_for_status(status)
22
+ raise exception_class, message
23
+ end
24
+ end
25
+
26
+ end
27
+ end
28
+ 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 "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
+
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 "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