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.
- checksums.yaml +7 -0
- data/.gitignore +4 -0
- data/.rspec +1 -0
- data/.travis.yml +17 -0
- data/.yardopts +2 -0
- data/CONTRIBUTING.md +26 -0
- data/Gemfile +10 -0
- data/LICENSE +7 -0
- data/README.md +1017 -0
- data/Rakefile +11 -0
- data/UPGRADE.md +101 -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/gemfiles/Gemfile.activemodel-5.0.x +7 -0
- data/her5.gemspec +30 -0
- data/lib/her.rb +19 -0
- data/lib/her/api.rb +120 -0
- data/lib/her/collection.rb +12 -0
- data/lib/her/errors.rb +104 -0
- data/lib/her/json_api/model.rb +57 -0
- data/lib/her/middleware.rb +12 -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/json_api_parser.rb +68 -0
- data/lib/her/middleware/parse_json.rb +28 -0
- data/lib/her/middleware/second_level_parse_json.rb +36 -0
- data/lib/her/model.rb +75 -0
- data/lib/her/model/associations.rb +141 -0
- data/lib/her/model/associations/association.rb +107 -0
- data/lib/her/model/associations/association_proxy.rb +45 -0
- data/lib/her/model/associations/belongs_to_association.rb +101 -0
- data/lib/her/model/associations/has_many_association.rb +101 -0
- data/lib/her/model/associations/has_one_association.rb +80 -0
- data/lib/her/model/attributes.rb +297 -0
- data/lib/her/model/base.rb +33 -0
- data/lib/her/model/deprecated_methods.rb +61 -0
- data/lib/her/model/http.rb +113 -0
- data/lib/her/model/introspection.rb +65 -0
- data/lib/her/model/nested_attributes.rb +84 -0
- data/lib/her/model/orm.rb +207 -0
- data/lib/her/model/parse.rb +221 -0
- data/lib/her/model/paths.rb +126 -0
- data/lib/her/model/relation.rb +164 -0
- data/lib/her/version.rb +3 -0
- data/spec/api_spec.rb +114 -0
- data/spec/collection_spec.rb +26 -0
- data/spec/json_api/model_spec.rb +305 -0
- data/spec/middleware/accept_json_spec.rb +10 -0
- data/spec/middleware/first_level_parse_json_spec.rb +62 -0
- data/spec/middleware/json_api_parser_spec.rb +32 -0
- data/spec/middleware/second_level_parse_json_spec.rb +35 -0
- data/spec/model/associations/association_proxy_spec.rb +31 -0
- data/spec/model/associations_spec.rb +504 -0
- data/spec/model/attributes_spec.rb +389 -0
- data/spec/model/callbacks_spec.rb +145 -0
- data/spec/model/dirty_spec.rb +91 -0
- data/spec/model/http_spec.rb +158 -0
- data/spec/model/introspection_spec.rb +76 -0
- data/spec/model/nested_attributes_spec.rb +134 -0
- data/spec/model/orm_spec.rb +506 -0
- data/spec/model/parse_spec.rb +345 -0
- data/spec/model/paths_spec.rb +347 -0
- data/spec/model/relation_spec.rb +226 -0
- data/spec/model/validations_spec.rb +42 -0
- data/spec/model_spec.rb +44 -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 +36 -0
- data/spec/support/macros/request_macros.rb +27 -0
- 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
|
data/lib/her/model.rb
ADDED
@@ -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
|