api_resource 0.6.21 → 0.6.22

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +1 -0
  3. data/Gemfile.lock +1 -1
  4. data/LICENSE.txt +22 -0
  5. data/README.md +16 -9
  6. data/docs/Attributes.md +64 -0
  7. data/docs/Caching.md +45 -0
  8. data/docs/GettingStarted.md +149 -0
  9. data/docs/Relationships.md +136 -0
  10. data/docs/ResourceDefinition.md +80 -0
  11. data/docs/Retrieval.md +279 -0
  12. data/docs/Serialization.md +56 -0
  13. data/lib/api_resource/associations/has_many_remote_object_proxy.rb +2 -2
  14. data/lib/api_resource/attributes.rb +16 -4
  15. data/lib/api_resource/base.rb +98 -19
  16. data/lib/api_resource/conditions/abstract_condition.rb +241 -129
  17. data/lib/api_resource/conditions/include_condition.rb +7 -1
  18. data/lib/api_resource/conditions/pagination_condition.rb +37 -0
  19. data/lib/api_resource/conditions/where_condition.rb +19 -0
  20. data/lib/api_resource/conditions.rb +18 -2
  21. data/lib/api_resource/connection.rb +27 -13
  22. data/lib/api_resource/exceptions.rb +11 -11
  23. data/lib/api_resource/finders/abstract_finder.rb +176 -95
  24. data/lib/api_resource/finders/multi_object_association_finder.rb +10 -9
  25. data/lib/api_resource/finders/resource_finder.rb +59 -49
  26. data/lib/api_resource/finders/single_finder.rb +5 -6
  27. data/lib/api_resource/finders/single_object_association_finder.rb +52 -51
  28. data/lib/api_resource/finders.rb +1 -1
  29. data/lib/api_resource/formats/file_upload_format.rb +75 -0
  30. data/lib/api_resource/formats.rb +4 -1
  31. data/lib/api_resource/response.rb +108 -0
  32. data/lib/api_resource/scopes.rb +62 -5
  33. data/lib/api_resource/serializer.rb +1 -1
  34. data/lib/api_resource/typecasters/boolean_typecaster.rb +1 -0
  35. data/lib/api_resource/typecasters/integer_typecaster.rb +1 -0
  36. data/lib/api_resource/typecasters/time_typecaster.rb +12 -4
  37. data/lib/api_resource/version.rb +1 -1
  38. data/lib/api_resource.rb +1 -0
  39. data/spec/lib/associations/has_one_remote_object_proxy_spec.rb +4 -4
  40. data/spec/lib/associations_spec.rb +3 -3
  41. data/spec/lib/attributes_spec.rb +16 -1
  42. data/spec/lib/base_spec.rb +121 -39
  43. data/spec/lib/conditions/{abstract_conditions_spec.rb → abstract_condition_spec.rb} +23 -11
  44. data/spec/lib/conditions/pagination_condition_spec.rb +88 -0
  45. data/spec/lib/finders/multi_object_association_finder_spec.rb +55 -27
  46. data/spec/lib/finders/resource_finder_spec.rb +26 -2
  47. data/spec/lib/finders/single_object_association_finder_spec.rb +14 -6
  48. data/spec/lib/finders_spec.rb +81 -81
  49. data/spec/lib/observing_spec.rb +3 -4
  50. data/spec/lib/response_spec.rb +18 -0
  51. data/spec/lib/scopes_spec.rb +25 -1
  52. data/spec/lib/typecasters/boolean_typecaster_spec.rb +1 -1
  53. data/spec/lib/typecasters/integer_typecaster_spec.rb +1 -1
  54. data/spec/lib/typecasters/time_typecaster_spec.rb +6 -0
  55. data/spec/support/files/bg-awesome.jpg +0 -0
  56. data/spec/support/mocks/test_resource_mocks.rb +26 -16
  57. data/spec/support/requests/test_resource_requests.rb +27 -23
  58. metadata +24 -4
@@ -1,110 +1,191 @@
1
1
 
2
2
  module ApiResource
3
3
 
4
- module Finders
5
-
6
- class AbstractFinder
7
-
8
- attr_accessor :condition, :klass
9
- attr_reader :found, :internal_object
10
-
11
- # TODO: Make this list longer since there are for sure more methods to delegate
12
- delegate :to_s, :inspect, :reload, :present?, :blank?, :size, :count, :to => :internal_object
13
-
14
- def initialize(klass, condition)
15
- @klass = klass
16
- @condition = condition
17
- @found = false
18
-
19
- @klass.load_resource_definition
20
- end
21
-
22
- def load
23
- raise NotImplementedError("Must be defined in a subclass")
24
- end
25
-
26
- def internal_object
27
- # If we've already tried to load return what we've got
28
- if instance_variable_defined?(:@internal_object)
29
- return instance_variable_get(:@internal_object)
30
- end
31
- # If we haven't tried to load then just call load
32
- self.load
33
- end
34
-
35
- def all(*args)
36
- if args.blank?
37
- self.internal_object
38
- else
39
- self.klass.send(:all, *args)
40
- end
41
- end
42
-
43
- # proxy unknown methods to the internal_object
44
- def method_missing(method, *args, &block)
45
- self.internal_object.send(method, *args, &block)
46
- end
47
-
48
- protected
49
-
50
- # This returns a hash of class_names (given by the condition object)
51
- # to an array of objects
52
- def load_includes(id_hash)
53
- # Quit early if the condition is not eager
54
- return {} unless self.condition.eager_load?
55
- # Otherwise go through each class_name that is included, and load the ids
56
- # given in id_hash, at this point we know all these associations have their
57
- # proper names
58
-
59
- hsh = HashWithIndifferentAccess.new
60
- id_hash = HashWithIndifferentAccess.new(id_hash)
61
- # load each individually
62
- self.condition.included_objects.inject(hsh) do |accum, assoc|
63
- id_hash[assoc].each_slice(400).each do |ids|
64
- accum[assoc.to_sym] ||= []
65
- accum[assoc.to_sym].concat(
66
- self.klass.association_class(assoc).all(
67
- :params => {:ids => ids}
68
- )
69
- )
70
- end
71
- accum
72
- end
73
-
74
- hsh
75
- end
76
-
77
- def apply_includes(objects, includes)
78
- Array.wrap(objects).each do |obj|
79
- includes.each_pair do |assoc, vals|
80
- ids_to_keep = Array.wrap(obj.send(obj.class.association_foreign_key_field(assoc)))
81
- to_keep = vals.select{|elm| ids_to_keep.include?(elm.id)}
82
- # if this is a single association take the first
83
- # TODO: subclass instead of this
84
- if self.klass.has_many?(assoc)
85
- obj.send("#{assoc}=", to_keep, false)
86
- else
87
- obj.send("#{assoc}=", to_keep.first, false)
88
- end
89
- end
90
- end
91
- end
92
-
93
- def build_load_path
94
- raise "This is not finding an association" unless self.condition.remote_path
4
+ module Finders
5
+
6
+ class AbstractFinder
7
+
8
+ attr_accessor :condition, :klass
9
+ attr_reader :found, :internal_object
10
+
11
+ # TODO: Make this list longer since there are for sure more methods to delegate
12
+ delegate :to_s, :inspect, :reload, :present?, :blank?, :size, :count, :to => :internal_object
13
+
14
+ def initialize(klass, condition)
15
+ @klass = klass
16
+ @condition = condition
17
+ @found = false
18
+
19
+ @klass.load_resource_definition
20
+ end
21
+
22
+ #
23
+ # Allows us to respond correctly to instance_of? based
24
+ # on our internal_object
25
+ #
26
+ # @param klass [Class] Class to check
27
+ #
28
+ # @return [Boolean]
29
+ def instance_of?(klass)
30
+ super || self.internal_object.instance_of?(klass)
31
+ end
32
+
33
+ #
34
+ # Allows us to respond correctly to is_a? based
35
+ # on our internal_object
36
+ #
37
+ # @param klass [Class] Class to check
38
+ #
39
+ # @return [Boolean]
40
+ def is_a?(klass)
41
+ super || self.internal_object.is_a?(klass)
42
+ end
43
+
44
+ #
45
+ # Allows us to respond correctly to kind_of? based
46
+ # on our internal_object
47
+ #
48
+ # @param klass [Class] Class to check
49
+ #
50
+ # @return [Boolean]
51
+ def kind_of?(klass)
52
+ self.is_a?(klass)
53
+ end
54
+
55
+ #
56
+ # Return the headers for our response from
57
+ # the server
58
+ #
59
+ # @return [Hash] Headers hash
60
+ def headers
61
+ self.response.try(:headers)
62
+ end
63
+
64
+ def internal_object
65
+ # If we've already tried to load return what we've got
66
+ if instance_variable_defined?(:@internal_object)
67
+ return instance_variable_get(:@internal_object)
68
+ end
69
+ # If we haven't tried to load then just call load
70
+ self.load
71
+ end
72
+
73
+ def load
74
+ raise NotImplementedError("Must be defined in a subclass")
75
+ end
76
+
77
+ #
78
+ # Offset returned from the server
79
+ #
80
+ # @return [Fixnum]
81
+ def offset
82
+ self.headers.try(:[], 'ApiResource-Offset').to_i
83
+ end
84
+
85
+ #
86
+ # Is this a paginated find?
87
+ #
88
+ # @return [Boolean]
89
+ def paginated?
90
+ @condition.paginated?
91
+ end
92
+
93
+ #
94
+ # Total number of entries the server has told us are
95
+ # in our collection
96
+ #
97
+ # @return [Fixnum]
98
+ def total_entries
99
+ self.headers.try(:[], 'ApiResource-Total-Entries').to_i
100
+ end
101
+
102
+ #
103
+ # Getter for our response from the server
104
+ #
105
+ # @return [ApiResource::Response]
106
+ def response
107
+ @response ||= begin
108
+ self.klass.connection.get(self.build_load_path)
109
+ end
110
+ end
111
+
112
+ def all(*args)
113
+ if args.blank?
114
+ self.internal_object
115
+ else
116
+ self.klass.send(:all, *args)
117
+ end
118
+ end
119
+
120
+ # proxy unknown methods to the internal_object
121
+ def method_missing(method, *args, &block)
122
+ self.internal_object.send(method, *args, &block)
123
+ end
124
+
125
+ protected
126
+
127
+ # This returns a hash of class_names (given by the condition object)
128
+ # to an array of objects
129
+ def load_includes(id_hash)
130
+ # Quit early if the condition is not eager
131
+ return {} unless self.condition.eager_load?
132
+ # Otherwise go through each class_name that is included, and load the ids
133
+ # given in id_hash, at this point we know all these associations have their
134
+ # proper names
135
+
136
+ hsh = HashWithIndifferentAccess.new
137
+ id_hash = HashWithIndifferentAccess.new(id_hash)
138
+ # load each individually
139
+ self.condition.included_objects.inject(hsh) do |accum, assoc|
140
+ id_hash[assoc].each_slice(400).each do |ids|
141
+ accum[assoc.to_sym] ||= []
142
+ accum[assoc.to_sym].concat(
143
+ self.klass.association_class(assoc).all(
144
+ :params => {:ids => ids}
145
+ )
146
+ )
147
+ end
148
+ accum
149
+ end
150
+
151
+ hsh
152
+ end
153
+
154
+ def apply_includes(objects, includes)
155
+ if !objects.is_a?(Enumerable)
156
+ objects = Array.wrap(objects)
157
+ end
158
+
159
+ objects.each do |obj|
160
+ includes.each_pair do |assoc, vals|
161
+ ids_to_keep = Array.wrap(obj.send(obj.class.association_foreign_key_field(assoc)))
162
+ to_keep = vals.select{|elm| ids_to_keep.include?(elm.id)}
163
+ # if this is a single association take the first
164
+ # TODO: subclass instead of this
165
+ if self.klass.has_many?(assoc)
166
+ obj.send("#{assoc}=", to_keep, false)
167
+ else
168
+ obj.send("#{assoc}=", to_keep.first, false)
169
+ end
170
+ end
171
+ end
172
+ end
173
+
174
+ def build_load_path
175
+ raise "This is not finding an association" unless self.condition.remote_path
95
176
 
96
177
  path = self.condition.remote_path
97
178
  # add a format if it doesn't exist and there is no query string yet
98
179
  path += ".#{self.klass.format.extension}" unless path =~ /\./ || path =~/\?/
99
180
  # add the query string, allowing for other user-provided options in the remote_path if we have options
100
181
  unless self.condition.blank_conditions?
101
- path += (path =~ /\?/ ? "&" : "?") + self.condition.to_query
182
+ path += (path =~ /\?/ ? "&" : "?") + self.condition.to_query
102
183
  end
103
184
  path
104
- end
185
+ end
105
186
 
106
- end
187
+ end
107
188
 
108
- end
189
+ end
109
190
 
110
191
  end
@@ -4,12 +4,17 @@ module ApiResource
4
4
 
5
5
  class MultiObjectAssociationFinder < AbstractFinder
6
6
 
7
+ delegate :select,
8
+ to: :internal_object
9
+
7
10
  # If they pass in the internal object just skip the first
8
11
  # step and apply the includes
9
12
  def initialize(klass, condition, internal_object = nil)
10
13
  super(klass, condition)
11
14
 
12
- @internal_object = internal_object
15
+ if internal_object
16
+ @internal_object = internal_object
17
+ end
13
18
  end
14
19
 
15
20
  def load
@@ -18,15 +23,11 @@ module ApiResource
18
23
  raise "Tried to load association without a remote path"
19
24
  end
20
25
 
21
- unless @internal_object
22
- data = self.klass.connection.get(self.build_load_path)
23
- return [] if data.blank?
26
+ return [] if self.response.blank?
24
27
 
25
- # handle non-array data for more flexibility in our endpoints
26
- data = [data] unless data.is_a?(Array)
27
-
28
- @internal_object = self.klass.instantiate_collection(data)
29
- end
28
+ @internal_object ||= self.klass.instantiate_collection(
29
+ Array.wrap(self.response)
30
+ )
30
31
 
31
32
  @loaded = true
32
33
 
@@ -1,59 +1,69 @@
1
1
  module ApiResource
2
2
 
3
- module Finders
4
-
5
- class ResourceFinder < AbstractFinder
6
-
7
- # this is a little bit simpler, it's always a collection and does
8
- # not require a remote path
9
- def load
10
- begin
11
- @loaded = true
12
- @internal_object = self.klass.connection.get(self.build_load_path)
13
- return [] if @internal_object.blank?
14
-
15
- if @internal_object.is_a?(Array)
16
- @internal_object = self.klass.instantiate_collection(@internal_object)
17
- else
18
- @internal_object = [self.klass.instantiate_record(@internal_object)]
19
- end
20
-
21
- id_hash = self.condition.included_objects.inject({}) do |accum, assoc|
22
- accum[assoc] = @internal_object.collect do |obj|
23
- obj.send(self.klass.association_foreign_key_field(assoc))
24
- end
25
- accum[assoc].flatten!
26
- accum[assoc].uniq!
27
- accum
28
- end
29
- included_objects = self.load_includes(id_hash)
30
-
31
- self.apply_includes(@internal_object, included_objects)
32
-
33
- # looks hacky, but we want to return only a single
34
- # object in case of a find call.
35
- if @internal_object.count == 1 && self.build_load_path =~ /find/
36
- @internal_object = @internal_object.first
37
- end
38
-
39
- return @internal_object
40
- rescue ApiResource::ResourceNotFound
41
- nil
42
- end
43
- @internal_object
44
- end
45
-
46
- protected
3
+ module Finders
4
+
5
+ class ResourceFinder < AbstractFinder
6
+
7
+ # this is a little bit simpler, it's always a collection and does
8
+ # not require a remote path
9
+ def load
10
+ begin
11
+ return [] if self.response.blank?
12
+
13
+ @loaded = true
14
+
15
+ if self.response.is_a?(Array)
16
+ @internal_object = self.klass.instantiate_collection(
17
+ self.response
18
+ )
19
+ else
20
+ @internal_object = [
21
+ self.klass.instantiate_record(self.response)
22
+ ]
23
+ end
24
+
25
+ id_hash = self.condition.included_objects.inject({}) do |accum, assoc|
26
+ accum[assoc] = @internal_object.collect do |obj|
27
+ obj.send(self.klass.association_foreign_key_field(assoc))
28
+ end
29
+ accum[assoc].flatten!
30
+ accum[assoc].uniq!
31
+ accum
32
+ end
33
+ included_objects = self.load_includes(id_hash)
34
+
35
+ self.apply_includes(@internal_object, included_objects)
36
+
37
+
38
+ # Removed to mirror ActiveRecord
39
+ # e.g. LifebookerClient::Provider.find([1])
40
+ #
41
+ # # looks hacky, but we want to return only a single
42
+ # # object in case of a find call.
43
+ # if @internal_object.count == 1 && self.build_load_path =~ /(&|\?)find/
44
+ # @internal_object = @internal_object.first
45
+ # end
46
+
47
+ return @internal_object
48
+ rescue ApiResource::ResourceNotFound
49
+ nil
50
+ end
51
+ @internal_object
52
+ end
53
+
54
+ protected
47
55
 
48
56
 
49
57
  # Find every resource
50
58
  def build_load_path
51
- prefix_opts, query_opts = self.klass.split_options(self.condition.to_hash)
52
- self.klass.collection_path(prefix_opts, query_opts)
59
+ prefix_opts, query_opts = self.klass.split_options(
60
+ self.condition.to_hash
61
+ )
62
+ self.klass.collection_path(prefix_opts, query_opts)
53
63
  end
54
64
 
55
- end
65
+ end
56
66
 
57
- end
67
+ end
58
68
 
59
- end
69
+ end
@@ -5,20 +5,19 @@ module ApiResource
5
5
  class SingleFinder < AbstractFinder
6
6
 
7
7
  def load
8
- data = self.klass.connection.get(self.build_load_path)
8
+ return nil if self.response.blank?
9
9
 
10
10
  @loaded = true
11
- return nil if data.blank?
12
11
 
13
- @internal_object = self.klass.instantiate_record(data)
12
+ @internal_object = self.klass.instantiate_record(self.response)
14
13
  # now that the object is loaded, resolve the includes
15
- id_hash = self.condition.included_objects.inject({}) do |accum, assoc|
16
- accum[assoc] = Array.wrap(
14
+ id_hash = self.condition.included_objects.inject({}) do |hash, assoc|
15
+ hash[assoc] = Array.wrap(
17
16
  @internal_object.send(
18
17
  @internal_object.class.association_foreign_key_field(assoc)
19
18
  )
20
19
  )
21
- accum
20
+ hash
22
21
  end
23
22
 
24
23
  included_objects = self.load_includes(id_hash)
@@ -1,55 +1,56 @@
1
1
  module ApiResource
2
2
 
3
- module Finders
4
-
5
- class SingleObjectAssociationFinder < AbstractFinder
6
-
7
- def initialize(klass, condition, internal_object = nil)
8
- super(klass, condition)
9
-
10
- @internal_object = internal_object
11
- end
12
-
13
- # since it is only a single object we can just load from
14
- # the service_uri and deal with includes
15
- def load
16
- # otherwise just instantiate the record
17
- unless self.condition.remote_path
18
- raise "Tried to load association without a remote path"
19
- end
20
-
21
- unless @internal_object
22
- data = self.klass.connection.get(self.build_load_path)
23
-
24
- return nil if data.blank?
25
-
26
- # we want to handle an array if we get one back from our endpoint
27
- # this allows for more flexibility
28
- data = data.first if data.is_a?(Array)
29
-
30
- @internal_object = self.klass.instantiate_record(data)
31
- end
32
-
33
- @loaded = true
34
- # now that the object is loaded, resolve the includes
35
- id_hash = self.condition.included_objects.inject({}) do |accum, assoc|
36
- accum[assoc] = Array.wrap(
37
- @internal_object.send(
38
- @internal_object.class.association_foreign_key_field(assoc)
39
- )
40
- )
41
- accum
42
- end
43
-
44
- included_objects = self.load_includes(id_hash)
45
-
46
- self.apply_includes(@internal_object, included_objects)
47
-
48
- return @internal_object
49
- end
50
-
51
- end
52
-
53
- end
3
+ module Finders
4
+
5
+ class SingleObjectAssociationFinder < AbstractFinder
6
+
7
+ def initialize(klass, condition, internal_object = nil)
8
+ super(klass, condition)
9
+ @internal_object = internal_object
10
+ end
11
+
12
+ # since it is only a single object we can just load from
13
+ # the service_uri and deal with includes
14
+ def load
15
+ # otherwise just instantiate the record
16
+ unless self.condition.remote_path
17
+ raise "Tried to load association without a remote path"
18
+ end
19
+
20
+ # check our response
21
+ return nil if self.response.blank?
22
+
23
+ # get our internal object
24
+ @internal_object ||= begin
25
+ if self.response.is_a?(Array)
26
+ self.klass.instantiate_record(self.response.first)
27
+ else
28
+ self.klass.instantiate_record(self.response)
29
+ end
30
+ end
31
+
32
+ # mark us as loaded
33
+ @loaded = true
34
+
35
+ # now that the object is loaded, resolve the includes
36
+ id_hash = self.condition.included_objects.inject({}) do |hash, assoc|
37
+ hash[assoc] = Array.wrap(
38
+ @internal_object.send(
39
+ @internal_object.class.association_foreign_key_field(assoc)
40
+ )
41
+ )
42
+ hash
43
+ end
44
+
45
+ # apply our includes
46
+ included_objects = self.load_includes(id_hash)
47
+ self.apply_includes(@internal_object, included_objects)
48
+
49
+ return @internal_object
50
+ end
51
+
52
+ end
53
+
54
+ end
54
55
 
55
56
  end
@@ -110,7 +110,7 @@ module ApiResource
110
110
  def arg_ary
111
111
  if @scope.blank?
112
112
  return :none
113
- elsif Array.wrap(@scope).size == 1
113
+ elsif !@scope.is_a?(Array)
114
114
  return :single
115
115
  else
116
116
  return :multiple
@@ -0,0 +1,75 @@
1
+ require 'active_support/json'
2
+ require 'json'
3
+
4
+ module ApiResource
5
+ module Formats
6
+
7
+
8
+ #
9
+ # @module FileUploadFormat
10
+ #
11
+ # Class to handle posting of multipart data to HTTPClient
12
+ #
13
+ module FileUploadFormat
14
+ extend self
15
+
16
+ #
17
+ # The extension for the request
18
+ #
19
+ # @return [String]
20
+ #
21
+ def extension
22
+ "json"
23
+ end
24
+
25
+ #
26
+ # The mime_type header for the request
27
+ #
28
+ # @return [String]
29
+ #
30
+ def mime_type
31
+ "multipart/form-data"
32
+ end
33
+
34
+ #
35
+ # Implementation of {#encode} - encodes data to POST
36
+ # to the server
37
+ #
38
+ # @return [Hash]
39
+ #
40
+ def encode(hash, options = nil)
41
+ ret = {}
42
+ hash.each_pair do |k,v|
43
+ ret[k] = self.encode_value(v)
44
+ end
45
+ ret
46
+ end
47
+
48
+ #
49
+ # Implementation of {#decode} - decodes data back from the server
50
+ # We expect the data to be JSON-formatted
51
+ #
52
+ # @return [Hash]
53
+ #
54
+ def decode(json)
55
+ JSON.parse(json)
56
+ end
57
+
58
+ protected
59
+
60
+ def encode_value(val)
61
+ case val
62
+ when Hash
63
+ self.encode(val)
64
+ when Array
65
+ val.collect{|v| self.encode_value(v)}
66
+ when ActionDispatch::Http::UploadedFile
67
+ val.tempfile
68
+ else
69
+ val
70
+ end
71
+ end
72
+
73
+ end
74
+ end
75
+ end
@@ -1,7 +1,10 @@
1
1
  module ApiResource
2
2
  module Formats
3
- autoload :XmlFormat, 'api_resource/formats/xml_format'
3
+
4
+ autoload :FileUploadFormat, 'api_resource/formats/file_upload_format'
4
5
  autoload :JsonFormat, 'api_resource/formats/json_format'
6
+ autoload :XmlFormat, 'api_resource/formats/xml_format'
7
+
5
8
 
6
9
  # Lookup the format class from a mime type reference symbol. Example:
7
10
  #