lazy_resource 0.1.0
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.
- data/.gitignore +18 -0
- data/.rspec +3 -0
- data/.rvmrc +1 -0
- data/Gemfile +14 -0
- data/Guardfile +5 -0
- data/LICENSE +22 -0
- data/README.md +127 -0
- data/Rakefile +6 -0
- data/examples/github.rb +64 -0
- data/lazy_resource.gemspec +22 -0
- data/lib/lazy_resource.rb +39 -0
- data/lib/lazy_resource/attributes.rb +131 -0
- data/lib/lazy_resource/errors.rb +72 -0
- data/lib/lazy_resource/http_mock.rb +23 -0
- data/lib/lazy_resource/mapping.rb +73 -0
- data/lib/lazy_resource/relation.rb +132 -0
- data/lib/lazy_resource/request.rb +63 -0
- data/lib/lazy_resource/resource.rb +197 -0
- data/lib/lazy_resource/resource_queue.rb +40 -0
- data/lib/lazy_resource/types.rb +53 -0
- data/lib/lazy_resource/url_generation.rb +98 -0
- data/lib/lazy_resource/version.rb +3 -0
- data/spec/fixtures/comment.rb +6 -0
- data/spec/fixtures/post.rb +6 -0
- data/spec/fixtures/user.rb +9 -0
- data/spec/lazy_resource/attributes_spec.rb +157 -0
- data/spec/lazy_resource/errors_spec.rb +48 -0
- data/spec/lazy_resource/mapping_spec.rb +237 -0
- data/spec/lazy_resource/relation_spec.rb +169 -0
- data/spec/lazy_resource/request_spec.rb +143 -0
- data/spec/lazy_resource/resource_queue_spec.rb +66 -0
- data/spec/lazy_resource/resource_spec.rb +463 -0
- data/spec/lazy_resource/types_spec.rb +109 -0
- data/spec/lazy_resource/url_generation_spec.rb +155 -0
- data/spec/spec_helper.rb +25 -0
- metadata +165 -0
@@ -0,0 +1,72 @@
|
|
1
|
+
module LazyResource
|
2
|
+
class ConnectionError < StandardError # :nodoc:
|
3
|
+
attr_reader :response
|
4
|
+
|
5
|
+
def initialize(response, message = nil)
|
6
|
+
@response = response
|
7
|
+
@message = message
|
8
|
+
end
|
9
|
+
|
10
|
+
def to_s
|
11
|
+
message = "Failed."
|
12
|
+
message << " Response code = #{response.code}." if response.respond_to?(:code)
|
13
|
+
message << " Response message = #{response.body}." if response.respond_to?(:body)
|
14
|
+
message
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# Raised when a Timeout::Error occurs.
|
19
|
+
class TimeoutError < ConnectionError
|
20
|
+
def initialize(message)
|
21
|
+
@message = message
|
22
|
+
end
|
23
|
+
def to_s; @message ;end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Raised when a OpenSSL::SSL::SSLError occurs.
|
27
|
+
class SSLError < ConnectionError
|
28
|
+
def initialize(message)
|
29
|
+
@message = message
|
30
|
+
end
|
31
|
+
def to_s; @message ;end
|
32
|
+
end
|
33
|
+
|
34
|
+
# 3xx Redirection
|
35
|
+
class Redirection < ConnectionError # :nodoc:
|
36
|
+
def to_s; (response.headers['Location'] || response.headers[:Location]) ? "#{super} => #{response.headers['Location'] || response.headers[:Location]}" : super; end
|
37
|
+
end
|
38
|
+
|
39
|
+
# 4xx Client Error
|
40
|
+
class ClientError < ConnectionError; end # :nodoc:
|
41
|
+
|
42
|
+
# 400 Bad Request
|
43
|
+
class BadRequest < ClientError; end # :nodoc
|
44
|
+
|
45
|
+
# 401 Unauthorized
|
46
|
+
class UnauthorizedAccess < ClientError; end # :nodoc
|
47
|
+
|
48
|
+
# 403 Forbidden
|
49
|
+
class ForbiddenAccess < ClientError; end # :nodoc
|
50
|
+
|
51
|
+
# 404 Not Found
|
52
|
+
class ResourceNotFound < ClientError; end # :nodoc:
|
53
|
+
|
54
|
+
# 409 Conflict
|
55
|
+
class ResourceConflict < ClientError; end # :nodoc:
|
56
|
+
|
57
|
+
# 410 Gone
|
58
|
+
class ResourceGone < ClientError; end # :nodoc:
|
59
|
+
|
60
|
+
# 422 Unprocessable Entity
|
61
|
+
class UnprocessableEntity < ClientError; end # :nodoc:
|
62
|
+
|
63
|
+
# 5xx Server Error
|
64
|
+
class ServerError < ConnectionError; end # :nodoc:
|
65
|
+
|
66
|
+
# 405 Method Not Allowed
|
67
|
+
class MethodNotAllowed < ClientError # :nodoc:
|
68
|
+
def allowed_methods
|
69
|
+
@response.headers['Allow'].split(',').map { |verb| verb.strip.downcase.to_sym }
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module LazyResource
|
2
|
+
class HttpMock
|
3
|
+
class Responder
|
4
|
+
[:post, :put, :get, :delete].each do |method|
|
5
|
+
module_eval <<-RUBY, __FILE__, __LINE__ + 1
|
6
|
+
def #{method}(path, body='', status=200, response_headers={})
|
7
|
+
request_queue.stub(:#{method}, path).and_return(Typhoeus::Response.new(:code => status, :headers => response_headers, :body => body, :time => 0.3))
|
8
|
+
end
|
9
|
+
RUBY
|
10
|
+
end
|
11
|
+
|
12
|
+
def request_queue
|
13
|
+
Thread.current[:request_queue] ||= Typhoeus::Hydra.new
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
class << self
|
18
|
+
def respond_to(*args)
|
19
|
+
yield Responder.new
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
module LazyResource
|
2
|
+
module Mapping
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
attr_accessor :fetched, :persisted
|
6
|
+
|
7
|
+
def fetched?
|
8
|
+
@fetched
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.root_node_name=(node)
|
12
|
+
@root_node_name = node
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.root_node_name
|
16
|
+
@root_node_name
|
17
|
+
end
|
18
|
+
|
19
|
+
module ClassMethods
|
20
|
+
def root_node_name=(node)
|
21
|
+
@root_node_name = node
|
22
|
+
end
|
23
|
+
|
24
|
+
def root_node_name
|
25
|
+
@root_node_name || LazyResource::Mapping.root_node_name
|
26
|
+
end
|
27
|
+
|
28
|
+
def load(objects)
|
29
|
+
if objects.is_a?(Array)
|
30
|
+
objects.map do |object|
|
31
|
+
self.new.load(object)
|
32
|
+
end
|
33
|
+
else
|
34
|
+
if self.root_node_name && objects.key?(self.root_node_name.to_s)
|
35
|
+
self.load(objects[self.root_node_name.to_s])
|
36
|
+
else
|
37
|
+
self.new.load(objects)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def load(hash, persisted=true)
|
44
|
+
hash.fetched = true and return hash if hash.kind_of?(LazyResource::Mapping)
|
45
|
+
|
46
|
+
self.tap do |resource|
|
47
|
+
resource.persisted = persisted
|
48
|
+
resource.fetched = false
|
49
|
+
|
50
|
+
hash = hash[resource.class.root_node_name.to_s] if resource.class.root_node_name && hash.key?(resource.class.root_node_name.to_s)
|
51
|
+
hash.each do |name, value|
|
52
|
+
attribute = self.class.attributes[name.to_sym]
|
53
|
+
next if attribute.nil?
|
54
|
+
|
55
|
+
type = attribute[:type]
|
56
|
+
if type.is_a?(::Array)
|
57
|
+
if type.first.include?(LazyResource::Mapping)
|
58
|
+
resource.send(:"#{name}=", type.first.load(value))
|
59
|
+
else
|
60
|
+
resource.send(:"#{name}=", value.map { |object| type.first.parse(object) })
|
61
|
+
end
|
62
|
+
elsif type.include?(LazyResource::Mapping)
|
63
|
+
resource.send(:"#{name}=", type.load(value))
|
64
|
+
else
|
65
|
+
resource.send(:"#{name}=", type.parse(value)) rescue StandardError
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
resource.fetched = true
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,132 @@
|
|
1
|
+
require 'active_support/core_ext/hash/slice'
|
2
|
+
|
3
|
+
module LazyResource
|
4
|
+
class Relation
|
5
|
+
class << self
|
6
|
+
def resource_queue
|
7
|
+
Thread.current[:resource_queue] ||= ResourceQueue.new
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
attr_accessor :fetched, :klass, :values, :from, :site
|
12
|
+
|
13
|
+
def initialize(klass, options = {})
|
14
|
+
@klass = klass
|
15
|
+
@values = options.slice(:where_values, :order_value, :limit_value, :offset_value, :page_value)
|
16
|
+
@fetched = options[:fetched] || false
|
17
|
+
unless fetched?
|
18
|
+
resource_queue.queue(self)
|
19
|
+
end
|
20
|
+
|
21
|
+
self
|
22
|
+
end
|
23
|
+
|
24
|
+
def from
|
25
|
+
@from || self.klass.collection_name
|
26
|
+
end
|
27
|
+
|
28
|
+
def collection_name
|
29
|
+
from
|
30
|
+
end
|
31
|
+
|
32
|
+
def to_params
|
33
|
+
params = {}
|
34
|
+
params.merge!(where_values) unless where_values.nil?
|
35
|
+
params.merge!(:order => order_value) unless order_value.nil?
|
36
|
+
params.merge!(:limit => limit_value) unless limit_value.nil?
|
37
|
+
params.merge!(:offset => offset_value) unless offset_value.nil?
|
38
|
+
params.merge!(:page => page_value) unless page_value.nil?
|
39
|
+
params
|
40
|
+
end
|
41
|
+
|
42
|
+
def load(objects)
|
43
|
+
@fetched = true
|
44
|
+
@result = @klass.load(objects)
|
45
|
+
end
|
46
|
+
|
47
|
+
def resource_queue
|
48
|
+
self.class.resource_queue
|
49
|
+
end
|
50
|
+
|
51
|
+
def where(where_values)
|
52
|
+
if @values[:where_values].nil?
|
53
|
+
@values[:where_values] = where_values
|
54
|
+
else
|
55
|
+
@values[:where_values].merge!(where_values)
|
56
|
+
end
|
57
|
+
|
58
|
+
self
|
59
|
+
end
|
60
|
+
|
61
|
+
def order(order_value)
|
62
|
+
@values[:order_value] = order_value
|
63
|
+
self
|
64
|
+
end
|
65
|
+
|
66
|
+
def limit(limit_value)
|
67
|
+
@values[:limit_value] = limit_value
|
68
|
+
self
|
69
|
+
end
|
70
|
+
|
71
|
+
def offset(offset_value)
|
72
|
+
@values[:offset_value] = offset_value
|
73
|
+
self
|
74
|
+
end
|
75
|
+
|
76
|
+
def page(page_value)
|
77
|
+
@values[:page_value] = page_value
|
78
|
+
self
|
79
|
+
end
|
80
|
+
|
81
|
+
def where_values
|
82
|
+
@values[:where_values]
|
83
|
+
end
|
84
|
+
|
85
|
+
def order_value
|
86
|
+
@values[:order_value]
|
87
|
+
end
|
88
|
+
|
89
|
+
def limit_value
|
90
|
+
@values[:limit_value]
|
91
|
+
end
|
92
|
+
|
93
|
+
def offset_value
|
94
|
+
@values[:offset_value]
|
95
|
+
end
|
96
|
+
|
97
|
+
def page_value
|
98
|
+
@values[:page_value]
|
99
|
+
end
|
100
|
+
|
101
|
+
def fetched?
|
102
|
+
@fetched
|
103
|
+
end
|
104
|
+
|
105
|
+
def to_a
|
106
|
+
resource_queue.run if !fetched?
|
107
|
+
result
|
108
|
+
end
|
109
|
+
|
110
|
+
def result
|
111
|
+
@result ||= []
|
112
|
+
end
|
113
|
+
|
114
|
+
def respond_to?(method, include_private = false)
|
115
|
+
super || result.respond_to?(method, include_private)
|
116
|
+
end
|
117
|
+
|
118
|
+
def as_json(options = {})
|
119
|
+
to_a.map do |record|
|
120
|
+
record.as_json
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
def method_missing(name, *args, &block)
|
125
|
+
if result.respond_to?(name)
|
126
|
+
self.to_a.send(name, *args, &block)
|
127
|
+
else
|
128
|
+
super
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
module LazyResource
|
2
|
+
class Request < Typhoeus::Request
|
3
|
+
SUCCESS_STATUSES = [200, 201]
|
4
|
+
|
5
|
+
attr_accessor :resource, :response
|
6
|
+
|
7
|
+
def initialize(url, resource, options={})
|
8
|
+
options = options.dup
|
9
|
+
options[:headers] ||= {}
|
10
|
+
options[:headers][:Accept] ||= 'application/json'
|
11
|
+
options[:headers].merge!(Thread.current[:default_headers]) unless Thread.current[:default_headers].nil?
|
12
|
+
options[:method] ||= :get
|
13
|
+
|
14
|
+
super(url, options)
|
15
|
+
|
16
|
+
@resource = resource
|
17
|
+
self.on_complete = on_complete_proc
|
18
|
+
|
19
|
+
self
|
20
|
+
end
|
21
|
+
|
22
|
+
def on_complete_proc
|
23
|
+
Proc.new do |response|
|
24
|
+
@response = response
|
25
|
+
handle_errors unless SUCCESS_STATUSES.include?(@response.code)
|
26
|
+
parse
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def parse
|
31
|
+
unless self.response.body.nil? || self.response.body == ''
|
32
|
+
@resource.load(JSON.parse(self.response.body))
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def handle_errors
|
37
|
+
case @response.code
|
38
|
+
when 300...400
|
39
|
+
raise Redirection.new(@response)
|
40
|
+
when 400
|
41
|
+
raise BadRequest.new(@response)
|
42
|
+
when 401
|
43
|
+
raise UnauthorizedAccess.new(@response)
|
44
|
+
when 403
|
45
|
+
raise ForbiddenAccess.new(@response)
|
46
|
+
when 404
|
47
|
+
raise ResourceNotFound.new(@response)
|
48
|
+
when 405
|
49
|
+
raise MethodNotAllowed.new(@response)
|
50
|
+
when 409
|
51
|
+
raise ResourceConflict.new(@response)
|
52
|
+
when 410
|
53
|
+
raise ResourceGone.new(@response)
|
54
|
+
when 422
|
55
|
+
raise UnprocessableEntity.new(@response)
|
56
|
+
when 400...500
|
57
|
+
raise ClientError.new(@response)
|
58
|
+
when 500...600
|
59
|
+
raise ServerError.new(@response)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,197 @@
|
|
1
|
+
module LazyResource
|
2
|
+
module Resource
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
include ActiveModel::Conversion
|
5
|
+
include Attributes, Mapping, Types, UrlGeneration
|
6
|
+
|
7
|
+
included do
|
8
|
+
extend ActiveModel::Callbacks
|
9
|
+
define_model_callbacks :create, :update, :save, :destroy
|
10
|
+
|
11
|
+
include ActiveModel::Validations
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.site=(site)
|
15
|
+
@site = site
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.site
|
19
|
+
@site
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.root_node_name=(node_name)
|
23
|
+
LazyResource::Mapping.root_node_name = node_name
|
24
|
+
end
|
25
|
+
|
26
|
+
module ClassMethods
|
27
|
+
# Gets the URI of the REST resources to map for this class. The site variable is required for
|
28
|
+
# Active Async's mapping to work.
|
29
|
+
def site
|
30
|
+
if defined?(@site)
|
31
|
+
@site
|
32
|
+
else
|
33
|
+
LazyResource::Resource.site
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Sets the URI of the REST resources to map for this class to the value in the +site+ argument.
|
38
|
+
# The site variable is required for Active Async's mapping to work.
|
39
|
+
def site=(site)
|
40
|
+
@site = site
|
41
|
+
end
|
42
|
+
|
43
|
+
def request_queue
|
44
|
+
Thread.current[:request_queue] ||= Typhoeus::Hydra.new
|
45
|
+
end
|
46
|
+
|
47
|
+
def find(id, params={}, options={})
|
48
|
+
self.new(self.primary_key_name => id).tap do |resource|
|
49
|
+
resource.fetched = false
|
50
|
+
resource.persisted = true
|
51
|
+
request = Request.new(resource.element_url(params), resource, options)
|
52
|
+
request_queue.queue(request)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def where(where_values)
|
57
|
+
Relation.new(self, :where_values => where_values)
|
58
|
+
end
|
59
|
+
|
60
|
+
def order(order_value)
|
61
|
+
Relation.new(self, :order_value => order_value)
|
62
|
+
end
|
63
|
+
|
64
|
+
def limit(limit_value)
|
65
|
+
Relation.new(self, :limit_value => limit_value)
|
66
|
+
end
|
67
|
+
|
68
|
+
def offset(offset_value)
|
69
|
+
Relation.new(self, :offset_value => offset_value)
|
70
|
+
end
|
71
|
+
|
72
|
+
def page(page_value)
|
73
|
+
Relation.new(self, :page_value => page_value)
|
74
|
+
end
|
75
|
+
|
76
|
+
def all
|
77
|
+
Relation.new(self)
|
78
|
+
end
|
79
|
+
|
80
|
+
def create(attributes={})
|
81
|
+
new(attributes).tap do |resource|
|
82
|
+
resource.create
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def initialize(attributes={})
|
88
|
+
self.tap do |resource|
|
89
|
+
resource.load(attributes, false)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# Tests for equality. Returns true iff +other+ is the same object or
|
94
|
+
# other is an instance of the same class and has the same attributes.
|
95
|
+
def ==(other)
|
96
|
+
return true if other.equal?(self)
|
97
|
+
return false unless other.instance_of?(self.class)
|
98
|
+
|
99
|
+
self.class.attributes.inject(true) do |memo, attribute|
|
100
|
+
attribute_name = attribute.first
|
101
|
+
attribute_type = attribute.last[:type]
|
102
|
+
|
103
|
+
# Skip associations
|
104
|
+
if attribute_type.include?(LazyResource::Resource) || (attribute_type.is_a?(::Array) && attribute_type.first.include?(LazyResource::Resource))
|
105
|
+
memo
|
106
|
+
else
|
107
|
+
memo && self.send(:"#{attribute_name}") == other.send(:"#{attribute_name}")
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def eql?(other)
|
113
|
+
self == other
|
114
|
+
end
|
115
|
+
|
116
|
+
def persisted?
|
117
|
+
@persisted
|
118
|
+
end
|
119
|
+
|
120
|
+
def new_record?
|
121
|
+
!persisted?
|
122
|
+
end
|
123
|
+
|
124
|
+
alias :new? :new_record?
|
125
|
+
|
126
|
+
def save
|
127
|
+
return true if !changed?
|
128
|
+
run_callbacks :save do
|
129
|
+
new_record? ? create : update
|
130
|
+
self.persisted = true
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def create
|
135
|
+
run_callbacks :create do
|
136
|
+
request = Request.new(self.collection_url, self, { :method => :post, :params => attribute_params })
|
137
|
+
self.class.request_queue.queue(request)
|
138
|
+
self.class.fetch_all
|
139
|
+
self.changed_attributes.clear
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
def update
|
144
|
+
run_callbacks :update do
|
145
|
+
request = Request.new(self.element_url, self, { :method => :put, :params => attribute_params })
|
146
|
+
self.class.request_queue.queue(request)
|
147
|
+
self.class.fetch_all
|
148
|
+
self.changed_attributes.clear
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
def destroy
|
153
|
+
run_callbacks :destroy do
|
154
|
+
request = Request.new(self.element_url, self, { :method => :delete })
|
155
|
+
self.class.request_queue.queue(request)
|
156
|
+
self.class.fetch_all
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
def update_attributes(attributes={})
|
161
|
+
attributes.each do |name, value|
|
162
|
+
self.send("#{name}=", value)
|
163
|
+
end
|
164
|
+
self.update
|
165
|
+
end
|
166
|
+
|
167
|
+
def attribute_params
|
168
|
+
{ self.class.element_name.to_sym => changed_attributes.inject({}) do |hash, changed_attribute|
|
169
|
+
hash.tap do |hash|
|
170
|
+
hash[changed_attribute.first] = self.send(changed_attribute.first)
|
171
|
+
end
|
172
|
+
end }
|
173
|
+
end
|
174
|
+
|
175
|
+
def as_json(options={})
|
176
|
+
self.class.attributes.inject({}) do |hash, (attribute_name, attribute_options)|
|
177
|
+
attribute_type = attribute_options[:type]
|
178
|
+
|
179
|
+
# Skip nil attributes (need to use instance_variable_get to avoid the stub relations that get added for associations."
|
180
|
+
unless self.instance_variable_get("@#{attribute_name}").nil?
|
181
|
+
value = self.send(:"#{attribute_name}")
|
182
|
+
|
183
|
+
if (attribute_type.is_a?(::Array) && attribute_type.first.include?(LazyResource::Resource))
|
184
|
+
value = value.map { |v| v.as_json }
|
185
|
+
elsif attribute_type.include?(LazyResource::Resource)
|
186
|
+
value = value.as_json
|
187
|
+
elsif attribute_type == DateTime
|
188
|
+
value = value.to_s
|
189
|
+
end
|
190
|
+
|
191
|
+
hash[attribute_name.to_sym] = value
|
192
|
+
end
|
193
|
+
hash
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|