api_resource 0.6.21 → 0.6.22

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