jsonapi-consumer 0.1.0.pre.1

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