jsonapi-consumer 0.1.0.pre.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 (46) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +14 -0
  3. data/.rspec +2 -0
  4. data/CHANGELOG.md +12 -0
  5. data/Gemfile +7 -0
  6. data/LICENSE.txt +176 -0
  7. data/README.md +37 -0
  8. data/Rakefile +12 -0
  9. data/jsonapi-consumer.gemspec +32 -0
  10. data/lib/jsonapi/consumer/errors.rb +98 -0
  11. data/lib/jsonapi/consumer/middleware/parse_json.rb +32 -0
  12. data/lib/jsonapi/consumer/middleware/raise_error.rb +21 -0
  13. data/lib/jsonapi/consumer/middleware/request_timeout.rb +9 -0
  14. data/lib/jsonapi/consumer/middleware.rb +5 -0
  15. data/lib/jsonapi/consumer/parser.rb +75 -0
  16. data/lib/jsonapi/consumer/query/base.rb +34 -0
  17. data/lib/jsonapi/consumer/query/create.rb +9 -0
  18. data/lib/jsonapi/consumer/query/delete.rb +10 -0
  19. data/lib/jsonapi/consumer/query/find.rb +16 -0
  20. data/lib/jsonapi/consumer/query/new.rb +15 -0
  21. data/lib/jsonapi/consumer/query/update.rb +11 -0
  22. data/lib/jsonapi/consumer/query.rb +5 -0
  23. data/lib/jsonapi/consumer/resource/association_concern.rb +203 -0
  24. data/lib/jsonapi/consumer/resource/attributes_concern.rb +70 -0
  25. data/lib/jsonapi/consumer/resource/connection_concern.rb +94 -0
  26. data/lib/jsonapi/consumer/resource/finders_concern.rb +28 -0
  27. data/lib/jsonapi/consumer/resource/object_build_concern.rb +28 -0
  28. data/lib/jsonapi/consumer/resource/serializer_concern.rb +64 -0
  29. data/lib/jsonapi/consumer/resource.rb +88 -0
  30. data/lib/jsonapi/consumer/version.rb +5 -0
  31. data/lib/jsonapi/consumer.rb +40 -0
  32. data/spec/fixtures/.gitkeep +0 -0
  33. data/spec/fixtures/resources.rb +33 -0
  34. data/spec/fixtures/responses.rb +51 -0
  35. data/spec/jsonapi/consumer/associations_spec.rb +141 -0
  36. data/spec/jsonapi/consumer/attributes_spec.rb +27 -0
  37. data/spec/jsonapi/consumer/connection_spec.rb +101 -0
  38. data/spec/jsonapi/consumer/error_handling_spec.rb +37 -0
  39. data/spec/jsonapi/consumer/object_build_spec.rb +20 -0
  40. data/spec/jsonapi/consumer/parser_spec.rb +41 -0
  41. data/spec/jsonapi/consumer/resource_spec.rb +62 -0
  42. data/spec/jsonapi/consumer/serializer_spec.rb +41 -0
  43. data/spec/spec_helper.rb +97 -0
  44. data/spec/support/.gitkeep +0 -0
  45. data/spec/support/load_fixtures.rb +4 -0
  46. metadata +242 -0
@@ -0,0 +1,203 @@
1
+ module JSONAPI::Consumer::Resource
2
+ class MisconfiguredAssociation < StandardError; end
3
+
4
+ module AssociationConcern
5
+ extend ActiveSupport::Concern
6
+
7
+ module ClassMethods
8
+ attr_writer :_associations
9
+
10
+ # Defines a has many relationship.
11
+ #
12
+ # @example
13
+ # class User
14
+ # include JSONAPI::Consumer::Resource
15
+ # has_many :articles, class_name: 'Article'
16
+ # end
17
+ def has_many(*attrs)
18
+ associate(:has_many, attrs)
19
+ end
20
+
21
+ # @todo belongs to is not supported yet.
22
+ #
23
+ def belongs_to(*attrs)
24
+ associate(:belongs_to, attrs)
25
+ end
26
+
27
+ # Defines a single relationship.
28
+ #
29
+ # @example
30
+ # class Article
31
+ # include JSONAPI::Consumer::Resource
32
+ # has_one :user, class_name: 'User'
33
+ # end
34
+ def has_one(*attrs)
35
+ associate(:has_one, attrs)
36
+ end
37
+
38
+ # :nodoc:
39
+ def _associations
40
+ @_associations ||= {}
41
+ end
42
+
43
+ # :nodoc:
44
+ def _association_for(name)
45
+ _associations[name.to_sym]
46
+ end
47
+
48
+ # :nodoc:
49
+ def _association_type(name)
50
+ _association_for(name).fetch(:type)
51
+ end
52
+
53
+ # :nodoc:
54
+ def _association_class_name(name)
55
+ if class_name = _association_for(name).fetch(:class_name)
56
+ begin
57
+ class_name.constantize
58
+ rescue NameError
59
+ raise MisconfiguredAssociation,
60
+ "#{self}##{_association_type(name)} #{name} has a class_name specified that does not exist."
61
+ end
62
+ else
63
+ raise MisconfiguredAssociation,
64
+ "#{self}##{_association_type(name)} #{name} is missing an explicit `:class_name` value."
65
+ end
66
+ end
67
+
68
+ # :nodoc:
69
+ def associate(type, attrs)
70
+ options = attrs.extract_options!
71
+
72
+ self._associations = _associations.dup
73
+
74
+ attrs.each do |attr|
75
+ unless method_defined?(attr)
76
+ define_method attr do
77
+ read_association(attr)
78
+ end
79
+ end
80
+
81
+ if type == :has_many
82
+ unless method_defined?(:"#{attr.to_s.singularize}_ids")
83
+ define_method :"#{attr.to_s.singularize}_ids" do
84
+ if objs = read_association(attr)
85
+ objs.collect {|o| o.send(o.primary_key)}
86
+ end
87
+ end
88
+ end
89
+ else
90
+ unless method_defined?(:"#{attr.to_s.singularize}_id")
91
+ define_method :"#{attr.to_s.singularize}_id" do
92
+ if obj = read_association(attr)
93
+ obj.send(obj.primary_key)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ unless method_defined?(:"#{attr}=")
99
+ define_method :"#{attr}=" do |val|
100
+ val = [val].flatten if type == :has_many && !val.nil?
101
+ set_association(attr, val)
102
+ end
103
+ end
104
+
105
+ self._associations[attr] = {type: type, class_name: options.delete(:class_name), options: options}
106
+ end
107
+ end
108
+ end
109
+
110
+ # :nodoc:
111
+ def each_association(&block)
112
+ self.class._associations.dup.each do |name, options|
113
+ association = self.send(name)
114
+
115
+ if block_given?
116
+ block.call(name, association, options[:options])
117
+ end
118
+ end
119
+ end
120
+
121
+ # Helper method that returns the names of defined associations.
122
+ #
123
+ # @return [Array<Symbol>] a list of association names
124
+ def association_names
125
+ self.class._associations.keys
126
+ end
127
+
128
+ protected
129
+
130
+
131
+ # Read the specified association.
132
+ #
133
+ # @param name [Symbol, String] the association name, `:users` or `:author`
134
+ #
135
+ # @return [Array, Object, nil] the value(s) of that association.
136
+ def read_association(name)
137
+ type = _association_type(name)
138
+ _associations.fetch(name, nil)
139
+ end
140
+
141
+ # Set values for the key'd association.
142
+ #
143
+ # @param key [Symbol] the association name, `:users` or `:author`
144
+ # @param value the value to set on the specified association
145
+ def set_association(key, value)
146
+ _associations[key.to_sym] = _cast_association(key, value)
147
+ end
148
+
149
+
150
+ # Helper method that verifies a given association exists.
151
+ #
152
+ # @param attr_name [String, Symbol] the association name
153
+ #
154
+ # @return [true, false]
155
+ def has_association?(attr_name)
156
+ _associations.has_key?(attr_name.to_sym)
157
+ end
158
+
159
+ private
160
+
161
+ # :nodoc:
162
+ def _cast_association(name, value)
163
+ return if value.is_a?(Array) && _association_type(name) != :has_many
164
+ return value if value.nil?
165
+
166
+ association_class = _association_class_name(name)
167
+
168
+ case value
169
+ when association_class
170
+ value
171
+ when Array
172
+ value.collect {|i| _cast_association(name, i) }
173
+ when Hash
174
+ association_class.new(value)
175
+ when NilClass
176
+ nil
177
+ else
178
+ association_class.new({association_class.primary_key => value})
179
+ end
180
+ end
181
+
182
+ # :nodoc:
183
+ def _association_for(name)
184
+ self.class._association_for(name)
185
+ end
186
+
187
+ # :nodoc:
188
+ def _association_type(name)
189
+ self.class._association_type(name)
190
+ end
191
+
192
+ # :nodoc:
193
+ def _association_class_name(name)
194
+ self.class._association_class_name(name)
195
+ end
196
+
197
+ # :nodoc:
198
+ def _associations
199
+ @associations ||= {}
200
+ end
201
+
202
+ end
203
+ end
@@ -0,0 +1,70 @@
1
+ module JSONAPI::Consumer::Resource
2
+ module AttributesConcern
3
+ extend ActiveSupport::Concern
4
+
5
+ include ActiveModel::AttributeMethods
6
+ include ActiveModel::Dirty
7
+
8
+ included do
9
+ attr_reader :attributes
10
+ end
11
+
12
+ def attributes=(attrs={})
13
+ @attributes ||= {}
14
+
15
+ return @attributes unless attrs.present?
16
+ attrs.each do |key, value|
17
+ set_attribute(key, value)
18
+ end
19
+ end
20
+
21
+ def update_attributes(attrs={})
22
+ self.attributes = attrs
23
+ # FIXME save
24
+ end
25
+
26
+ def persisted?
27
+ !self.to_param.blank?
28
+ end
29
+
30
+ def to_param
31
+ attributes.fetch(primary_key, '').to_s
32
+ end
33
+
34
+ # def [](key)
35
+ # read_attribute(key)
36
+ # end
37
+
38
+ # def []=(key, value)
39
+ # set_attribute(key, value)
40
+ # end
41
+
42
+ alias :respond_to_without_attributes? :respond_to?
43
+ def respond_to?(method, include_private_methods=false)
44
+ if super
45
+ true
46
+ elsif !include_private_methods && super(method, true)
47
+ # If we're here then we haven't found among non-private methods
48
+ # but found among all methods. Which means that the given method is private.
49
+ false
50
+ else
51
+ has_attribute?(method)
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def read_attribute(name)
58
+ attributes.fetch(name, nil)
59
+ end
60
+
61
+ def set_attribute(key, value)
62
+ attributes[key.to_sym] = value
63
+ end
64
+
65
+ def has_attribute?(attr_name)
66
+ attributes.has_key?(attr_name.to_sym)
67
+ end
68
+
69
+ end
70
+ end
@@ -0,0 +1,94 @@
1
+ module JSONAPI::Consumer
2
+ module ConnectionConcern
3
+ extend ActiveSupport::Concern
4
+
5
+ module ClassMethods
6
+ def parser_class
7
+ @parser ||= Parser
8
+ end
9
+
10
+ def parse(response)
11
+ parser = parser_class.new(self, response)
12
+
13
+ if response.status && response.status == 204
14
+ true
15
+ else
16
+ parser.build
17
+ end
18
+ end
19
+
20
+ # :nodoc:
21
+ def _run_request(query_object)
22
+ parse(_connection.send(query_object.request_method, query_object.path, query_object.params, query_object.headers))
23
+ end
24
+
25
+ # :nodoc:
26
+ def _connection
27
+ @connection ||= begin
28
+ Faraday.new(url: self.host, ssl: self.ssl) do |conn|
29
+ conn.request :json
30
+
31
+ conn.use Middleware::RequestTimeout
32
+ conn.use Middleware::ParseJson
33
+
34
+ conn.use Middleware::RaiseError
35
+ conn.adapter Faraday.default_adapter
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ def is_valid?
42
+ errors.empty?
43
+ end
44
+
45
+ def save
46
+ query = persisted? ?
47
+ Query::Update.new(self.class, self.serializable_hash) :
48
+ Query::Create.new(self.class, self.serializable_hash)
49
+
50
+ results = run_request(query)
51
+
52
+ if self.errors.empty?
53
+ self.attributes = results.first.attributes
54
+ true
55
+ else
56
+ false
57
+ end
58
+ end
59
+
60
+ def destroy
61
+ if run_request(Query::Delete.new(self.class, self.serializable_hash))
62
+ self.attributes.clear
63
+ true
64
+ else
65
+ false
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ # :nodoc:
72
+ def run_request(*args)
73
+ begin
74
+ self.errors.clear
75
+ request = self.class._run_request(*args)
76
+ rescue JSONAPI::Consumer::Errors::BadRequest => e
77
+ e.errors.map do |error|
78
+ process_error(error.dup)
79
+ end
80
+ end
81
+ end
82
+
83
+ # :nodoc:
84
+ def process_error(err)
85
+ field = err.fetch('path', '')
86
+ attr = field.match(/\A\/(\w+)\z/)
87
+ if attr[1] && has_attribute?(attr[1])
88
+ self.errors.add(attr[1].to_sym, err.fetch('detail', ''))
89
+ else
90
+ self.errors.add(:base, err.fetch('detail', ''))
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,28 @@
1
+ module JSONAPI::Consumer::Resource
2
+ module FindersConcern
3
+ extend ActiveSupport::Concern
4
+
5
+ module ClassMethods
6
+ def all(options={})
7
+ _run_request(JSONAPI::Consumer::Query::Find.new(self, options))
8
+ end
9
+
10
+ def find(options)
11
+ options = {self.primary_key => options} unless options.is_a?(Hash)
12
+ _run_request(JSONAPI::Consumer::Query::Find.new(self, options))
13
+ end
14
+
15
+ def primary_key
16
+ @primary_key ||= :id
17
+ end
18
+
19
+ def primary_key=(val)
20
+ @primary_key = val.to_sym
21
+ end
22
+ end
23
+
24
+ def primary_key
25
+ self.class.primary_key
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,28 @@
1
+ module JSONAPI::Consumer::Resource
2
+ module ObjectBuildConcern
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ class_attribute :request_new_object_on_build
7
+ end
8
+
9
+ module ClassMethods
10
+
11
+ # If class attribute `request_new_object_on_build`:
12
+ #
13
+ # True:
14
+ # will send a request to `{path}/new` to get an attributes list
15
+ #
16
+ # False:
17
+ # acts as an alias for `new`
18
+ #
19
+ def build
20
+ if !!self.request_new_object_on_build
21
+ _run_request(JSONAPI::Consumer::Query::New.new(self, {})).first
22
+ else
23
+ new
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,64 @@
1
+ module JSONAPI::Consumer::Resource
2
+ module SerializerConcern
3
+ extend ActiveSupport::Concern
4
+
5
+ def serializable_hash(options={})
6
+ @hash = persisted? ? attributes : attributes.except(self.class.primary_key)
7
+
8
+ self.each_association do |name, association, options|
9
+ @hash[:links] ||= {}
10
+ # unless options[:embed] == :ids
11
+ # @hash[:linked] ||= {}
12
+ # end
13
+
14
+ if association.respond_to?(:each)
15
+ add_links(name, association, options)
16
+ else
17
+ add_link(name, association, options)
18
+ end
19
+ end
20
+
21
+ @hash
22
+ end
23
+
24
+ def add_links(name, association, options)
25
+ @hash[:links][name] ||= []
26
+ @hash[:links][name] += association.map do |obj|
27
+ case obj.class
28
+ when String, Integer
29
+ obj
30
+ else
31
+ obj.to_param
32
+ end
33
+ end
34
+
35
+ # unless options[:embed] == :ids
36
+ # @hash[:linked][name] ||= []
37
+ # @hash[:linked][name] += association.map { |item| item.attributes(options) }
38
+ # end
39
+ end
40
+
41
+ def add_link(name, association, options)
42
+ return if association.nil?
43
+
44
+ @hash[:links][name] = case association.class
45
+ when String, Integer
46
+ association
47
+ else
48
+ association.to_param
49
+ end
50
+
51
+ # unless options[:embed] == :ids
52
+ # plural_name = name.to_s.pluralize.to_sym
53
+
54
+ # @hash[:linked][plural_name] ||= []
55
+ # @hash[:linked][plural_name].push association.attributes(options)
56
+ # end
57
+ end
58
+
59
+ def to_json(options={})
60
+ serializable_hash(options).to_json
61
+ end
62
+
63
+ end
64
+ end
@@ -0,0 +1,88 @@
1
+ module JSONAPI::Consumer
2
+ module Resource
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ extend ActiveModel::Naming
7
+
8
+ attr_reader :errors
9
+ class_attribute :host
10
+ end
11
+
12
+ include ObjectBuildConcern
13
+ include AttributesConcern
14
+ include AssociationConcern
15
+ include FindersConcern
16
+ include SerializerConcern
17
+ include ConnectionConcern
18
+
19
+ module ClassMethods
20
+ def json_key
21
+ self.name.demodulize.pluralize.underscore
22
+ end
23
+
24
+ def host
25
+ @host || raise(NotImplementedError, 'host was not set')
26
+ end
27
+
28
+ def path
29
+ json_key
30
+ end
31
+
32
+ def ssl
33
+ {}
34
+ end
35
+
36
+ private
37
+
38
+ def human_attribute_name(attr, options = {})
39
+ attr
40
+ end
41
+
42
+ def lookup_ancestors
43
+ [self]
44
+ end
45
+ end
46
+
47
+ def initialize(params={})
48
+ (params || {}).slice(*association_names).each do |key, value|
49
+ send(:"#{key}=", value)
50
+ end
51
+
52
+ self.attributes = params.except(*association_names) if params
53
+ @errors = ActiveModel::Errors.new(self)
54
+ super()
55
+ end
56
+
57
+ # Returns an Enumerable of all key attributes if any is set, regardless
58
+ # if the object is persisted or not.
59
+ # Returns nil if there are no key attributes.
60
+ #
61
+ # (see ActiveModel::Conversion#to_key)
62
+ def to_key
63
+ to_param ? [to_param] : nil
64
+ end
65
+
66
+ private
67
+
68
+ def read_attribute_for_validation(attr)
69
+ read_attribute(attr)
70
+ end
71
+
72
+ def method_missing(method, *args, &block)
73
+ if respond_to_without_attributes?(method, true)
74
+ super
75
+ else
76
+ if method.to_s =~ /^(.*)=$/
77
+ set_attribute($1, args.first)
78
+ elsif has_attribute?(method)
79
+ read_attribute(method)
80
+ elsif has_association?(method)
81
+ read_assocation(method)
82
+ else
83
+ super
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,5 @@
1
+ module Jsonapi
2
+ module Consumer
3
+ VERSION = "0.1.0.pre.1"
4
+ end
5
+ end
@@ -0,0 +1,40 @@
1
+ require "jsonapi/consumer/version"
2
+
3
+ require 'faraday'
4
+ require 'faraday_middleware'
5
+ require 'active_model'
6
+
7
+ require "active_support/concern"
8
+ require "active_support/core_ext"
9
+ require "active_support/inflector"
10
+
11
+ module JSONAPI
12
+ module Consumer
13
+
14
+ end
15
+ end
16
+
17
+ require "jsonapi/consumer/errors"
18
+
19
+ require "jsonapi/consumer/middleware"
20
+ require "jsonapi/consumer/middleware/parse_json"
21
+ require "jsonapi/consumer/middleware/raise_error"
22
+ require "jsonapi/consumer/middleware/request_timeout"
23
+
24
+ require "jsonapi/consumer/parser"
25
+
26
+ require "jsonapi/consumer/query"
27
+ require "jsonapi/consumer/query/base"
28
+ require "jsonapi/consumer/query/create"
29
+ require "jsonapi/consumer/query/delete"
30
+ require "jsonapi/consumer/query/find"
31
+ require "jsonapi/consumer/query/new"
32
+ require "jsonapi/consumer/query/update"
33
+
34
+ require "jsonapi/consumer/resource/association_concern"
35
+ require "jsonapi/consumer/resource/attributes_concern"
36
+ require "jsonapi/consumer/resource/connection_concern"
37
+ require "jsonapi/consumer/resource/finders_concern"
38
+ require "jsonapi/consumer/resource/object_build_concern"
39
+ require "jsonapi/consumer/resource/serializer_concern"
40
+ require "jsonapi/consumer/resource"
File without changes
@@ -0,0 +1,33 @@
1
+ class Base
2
+ include JSONAPI::Consumer::Resource
3
+
4
+ self.host = 'http://localhost:3000/api/'
5
+ end
6
+
7
+
8
+ class BasicResource < Base
9
+
10
+ end
11
+
12
+ class BuildRequest < Base
13
+ self.request_new_object_on_build = true
14
+ end
15
+
16
+
17
+ # BEGIN - Blog example
18
+ module Blog
19
+ class Author < Base
20
+ has_many :posts, class_name: 'Blog::Post'
21
+ end
22
+
23
+ class Post < Base
24
+ has_one :author, class_name: 'Blog::Author'
25
+ has_many :comments, class_name: 'Blog::Comment'
26
+ end
27
+
28
+ class Comment < Base
29
+ # belongs_to :post, class_name: 'Blog::Post'
30
+ # has_one :author, class_name: 'Blog::Author'
31
+ end
32
+ end
33
+ # END - Blog example
@@ -0,0 +1,51 @@
1
+ module Responses
2
+ def self.sideload
3
+ {
4
+ posts: [
5
+ {
6
+ links: {
7
+ comments: [
8
+ "82083863-bba9-480e-a281-f5d34e7dc0ca",
9
+ "3b402e8a-7c35-4915-8c72-07ea7779ab76"
10
+ ]
11
+ },
12
+ id: "e6d1b7ac-80d8-40dd-877d-f5bd40feabfb",
13
+ title: "Friday Post",
14
+ created_at: "2014-10-19T22:32:52.913Z",
15
+ updated_at: "2014-10-19T22:32:52.967Z"
16
+ },
17
+ {
18
+ links: {
19
+ comments: [
20
+ "9c9ba83b-024c-4d4c-9573-9fd41b95fc14",
21
+ "27fcf6e8-24b0-41db-94b1-812046a10f54"
22
+ ]
23
+ },
24
+ id: "ea006f14-6d05-4e87-bfe7-ee8ae3358840",
25
+ title: "Monday Post",
26
+ created_at: "2014-10-19T22:32:52.933Z",
27
+ updated_at: "2014-10-19T22:32:52.969Z"
28
+ }
29
+ ],
30
+ linked: {
31
+ comments: [
32
+ {
33
+ id: "82083863-bba9-480e-a281-f5d34e7dc0ca",
34
+ content: "Awesome article",
35
+ created_at: "2014-10-19T22:32:52.933Z",
36
+ updated_at: "2014-10-19T22:32:52.969Z"
37
+ },
38
+ {
39
+ id: "3b402e8a-7c35-4915-8c72-07ea7779ab76",
40
+ content: "Hated it",
41
+ created_at: "2014-10-19T22:32:52.933Z",
42
+ updated_at: "2014-10-19T22:32:52.969Z"
43
+ }
44
+ ]
45
+ },
46
+ links: {
47
+ :"posts.comments" => "http://localhost:3000/api/comments/{posts.comments}"
48
+ }
49
+ }.with_indifferent_access
50
+ end
51
+ end