Roman2K-web-service 0.1.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.
- data/LICENSE +7 -0
- data/README.mdown +147 -0
- data/Rakefile +39 -0
- data/lib/web_service.rb +36 -0
- data/lib/web_service/attribute_accessors.rb +147 -0
- data/lib/web_service/core_ext.rb +36 -0
- data/lib/web_service/crud_operations.rb +114 -0
- data/lib/web_service/named_request_methods.rb +19 -0
- data/lib/web_service/remote_collection.rb +155 -0
- data/lib/web_service/resource.rb +177 -0
- data/lib/web_service/response_handling.rb +148 -0
- data/lib/web_service/site.rb +40 -0
- data/test/test_helper.rb +59 -0
- data/test/web_service/attribute_accessors_test.rb +236 -0
- data/test/web_service/core_ext_test.rb +13 -0
- data/test/web_service/crud_operations_test.rb +61 -0
- data/test/web_service/named_request_methods_test.rb +34 -0
- data/test/web_service/remote_collection_test.rb +114 -0
- data/test/web_service/resource_test.rb +200 -0
- data/test/web_service/response_handling_test.rb +66 -0
- data/test/web_service/site_test.rb +62 -0
- data/web-service.gemspec +44 -0
- metadata +137 -0
@@ -0,0 +1,19 @@
|
|
1
|
+
module WebService
|
2
|
+
module NamedRequestMethods
|
3
|
+
def post(*args, &block)
|
4
|
+
request(:post, *args, &block).data
|
5
|
+
end
|
6
|
+
|
7
|
+
def get(*args, &block)
|
8
|
+
request(:get, *args, &block).data
|
9
|
+
end
|
10
|
+
|
11
|
+
def put(*args, &block)
|
12
|
+
request(:put, *args, &block).data
|
13
|
+
end
|
14
|
+
|
15
|
+
def delete(*args, &block)
|
16
|
+
request(:delete, *args, &block).data
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,155 @@
|
|
1
|
+
module WebService
|
2
|
+
class RemoteCollection
|
3
|
+
ARGUMENT_LAYOUT_FOR_REQUEST = [ [Integer, /^[^\/]/], # ID
|
4
|
+
[Symbol, /^\//], # Action
|
5
|
+
Hash ].freeze # Body
|
6
|
+
|
7
|
+
# http://github.com/thoughtbot/suspenders/tree/master/config/initializers/errors.rb
|
8
|
+
HTTP_ERRORS = [ EOFError,
|
9
|
+
Errno::EINVAL,
|
10
|
+
Errno::ECONNRESET,
|
11
|
+
Net::ProtocolError,
|
12
|
+
Net::HTTPBadResponse,
|
13
|
+
Net::HTTPHeaderSyntaxError ].freeze
|
14
|
+
|
15
|
+
attr_reader :resource_class
|
16
|
+
attr_reader :nesting
|
17
|
+
|
18
|
+
include ResponseHandling
|
19
|
+
|
20
|
+
def initialize(resource_details, nesting=[])
|
21
|
+
@resource_class, @nesting = resource_details, nesting
|
22
|
+
end
|
23
|
+
|
24
|
+
def request(method, *args)
|
25
|
+
id, action, body = recognize(ARGUMENT_LAYOUT_FOR_REQUEST, *args)
|
26
|
+
|
27
|
+
url = build_url_for(id, action)
|
28
|
+
content_type, body = perform_adjustments_for_body!(body, method, url)
|
29
|
+
request = instantiate_request_for(method, url)
|
30
|
+
request.content_type = content_type if content_type
|
31
|
+
WebService.logger.info do
|
32
|
+
"#{method.to_s.upcase} #{url.obfuscate}#{" (#{body.length} bytes)" if body}"
|
33
|
+
end
|
34
|
+
response, elapsed = handle_connection_errors do
|
35
|
+
benchmark do
|
36
|
+
open_http_connection_to(url) do |conn|
|
37
|
+
conn.request(request, body).extend ResponseDataUnserialization
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
WebService.logger.info do
|
42
|
+
status = "=> %d %s" % [response.code, response.message]
|
43
|
+
length = "(%.2f KB)" % [response.content_length / 1024.0] if response.content_length
|
44
|
+
time = "[%d ms]" % [elapsed * 1000]
|
45
|
+
[status, length, time].compact.join(' ')
|
46
|
+
end
|
47
|
+
handle_response(response)
|
48
|
+
end
|
49
|
+
|
50
|
+
def with_nesting(further_nesting)
|
51
|
+
self.class.new(resource_class, nesting + further_nesting)
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
# Handle the body differently depending on the request method.
|
57
|
+
def build_url_for(id, action)
|
58
|
+
if resource_class.singleton
|
59
|
+
raise ArgumentError, "singleton resources do not require an ID parameter" if id
|
60
|
+
else
|
61
|
+
id ||= implicit_id if respond_to?(:implicit_id)
|
62
|
+
end
|
63
|
+
|
64
|
+
url = ensure_url_copy(resource_class.site)
|
65
|
+
segments = [url.path]
|
66
|
+
nesting.each do |res_name, id_for_association|
|
67
|
+
segments << res_name.to_s.pluralize
|
68
|
+
raise "attribute `#{res_name}_id' is missing" unless id_for_association
|
69
|
+
segments << id_for_association
|
70
|
+
end
|
71
|
+
segments << (resource_class.singleton ? resource_class.element_name : resource_class.element_name.pluralize)
|
72
|
+
segments << (CGI.escape(id.to_s) if id) << action
|
73
|
+
url.path = segments.compact.join('/').squeeze('/')
|
74
|
+
return url
|
75
|
+
end
|
76
|
+
|
77
|
+
def instantiate_request_for(method, url)
|
78
|
+
request = Net::HTTP.const_get(method.to_s.capitalize).new([url.path, url.query].compact.join('?'))
|
79
|
+
request.basic_auth(*resource_class.credentials) if resource_class.credentials
|
80
|
+
request['Accept'] = ["application/json", "application/xml"]
|
81
|
+
return request
|
82
|
+
end
|
83
|
+
|
84
|
+
def perform_adjustments_for_body!(body, method, url)
|
85
|
+
case method
|
86
|
+
when :post, :put
|
87
|
+
["application/json", body.to_json]
|
88
|
+
else
|
89
|
+
url.query = body.to_query if body.respond_to?(:to_query) && body.method(:to_query).arity <= 0
|
90
|
+
[nil, nil]
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def open_http_connection_to(url)
|
95
|
+
http = Net::HTTP.new(url.host, url.port)
|
96
|
+
http.open_timeout = http.read_timeout = 30
|
97
|
+
http.use_ssl = url.kind_of?(URI::HTTPS)
|
98
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE if http.use_ssl
|
99
|
+
http.start do |conn|
|
100
|
+
yield conn
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def handle_connection_errors
|
105
|
+
yield
|
106
|
+
rescue Timeout::Error
|
107
|
+
raise TimeoutError.new($!.message)
|
108
|
+
rescue *HTTP_ERRORS
|
109
|
+
raise ConnectionError.new(nil, $!.message)
|
110
|
+
end
|
111
|
+
|
112
|
+
private # utilities
|
113
|
+
|
114
|
+
def recognize(patterns, *objects)
|
115
|
+
patterns.map do |pattern|
|
116
|
+
if pos = objects.index { |object| case object; when *pattern; true; end }
|
117
|
+
objects.delete_at(pos)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def benchmark
|
123
|
+
result = nil
|
124
|
+
elapsed = Benchmark.realtime do
|
125
|
+
result = yield
|
126
|
+
end
|
127
|
+
return result, elapsed
|
128
|
+
end
|
129
|
+
|
130
|
+
def ensure_url_copy(url)
|
131
|
+
url = url.url if url.respond_to?(:url)
|
132
|
+
url.kind_of?(URI) ? url.dup : URI.parse(url)
|
133
|
+
end
|
134
|
+
|
135
|
+
module ResponseDataUnserialization
|
136
|
+
def data
|
137
|
+
return @data if defined? @data
|
138
|
+
@data = parse_data
|
139
|
+
end
|
140
|
+
|
141
|
+
private
|
142
|
+
|
143
|
+
def parse_data
|
144
|
+
case content_type
|
145
|
+
when /json/
|
146
|
+
ActiveSupport::JSON.decode(body)
|
147
|
+
when /xml/
|
148
|
+
Hash.from_xml(body)
|
149
|
+
else
|
150
|
+
body.blank? ? nil : body
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
@@ -0,0 +1,177 @@
|
|
1
|
+
module WebService
|
2
|
+
class Resource
|
3
|
+
class_inheritable_attr_accessor :site, :credentials
|
4
|
+
class_inheritable_attr_accessor :element_name, :singleton
|
5
|
+
|
6
|
+
class << self
|
7
|
+
include NamedRequestMethods
|
8
|
+
include CRUDOperations
|
9
|
+
|
10
|
+
def credentials
|
11
|
+
super || site.credentials
|
12
|
+
end
|
13
|
+
|
14
|
+
def element_name
|
15
|
+
super || self.element_name = begin
|
16
|
+
klass = self
|
17
|
+
while klass.name.to_s.empty?
|
18
|
+
klass = klass.superclass
|
19
|
+
raise NameError, "cannot determine element name from anonymous class" unless klass < Resource
|
20
|
+
end
|
21
|
+
klass.name.to_s.demodulize.underscore
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def belongs_to(*resource_names)
|
26
|
+
@belongs_to ||= []
|
27
|
+
@belongs_to |= resource_names
|
28
|
+
@belongs_to
|
29
|
+
end
|
30
|
+
|
31
|
+
def has_many(*resource_names)
|
32
|
+
resource_names.each do |res_name|
|
33
|
+
class_eval <<-RUBY
|
34
|
+
def #{res_name}
|
35
|
+
association_collection_from_name(#{res_name.to_sym.inspect})
|
36
|
+
end
|
37
|
+
def #{res_name}=(collection)
|
38
|
+
#{res_name}.cache = collection
|
39
|
+
end
|
40
|
+
RUBY
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def has_one(*resource_names)
|
45
|
+
resource_names.each do |res_name|
|
46
|
+
forwardings =
|
47
|
+
{ "#{res_name}" => :first,
|
48
|
+
"build_#{res_name}" => :build }
|
49
|
+
|
50
|
+
forwardings.each do |from, to|
|
51
|
+
class_eval <<-RUBY
|
52
|
+
def #{from}(*args, &block)
|
53
|
+
association_collection_from_name(#{res_name.to_sym.inspect}, :singleton => true).#{to}(*args, &block)
|
54
|
+
end
|
55
|
+
RUBY
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
protected
|
61
|
+
|
62
|
+
delegate :request, :to => :remote_collection
|
63
|
+
|
64
|
+
def remote_collection
|
65
|
+
@remote_collection ||= RemoteCollection.new(self)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
include AttributeAccessors
|
70
|
+
include NamedRequestMethods
|
71
|
+
include CRUDOperations
|
72
|
+
|
73
|
+
alias to_hash :attributes
|
74
|
+
|
75
|
+
def to_s
|
76
|
+
[self.class, saved? ? "[#{id}]" : "(new)"].join
|
77
|
+
end
|
78
|
+
|
79
|
+
def inspect
|
80
|
+
type_with_id = [self.class, saved? ? "[#{id}]" : "(new)"].join
|
81
|
+
displayable_attributes_pairs =
|
82
|
+
attributes.map { |name, value|
|
83
|
+
value = value[0, 22] + '...' if String === value && value.length > 25
|
84
|
+
"#{name}=#{value.inspect}" unless name == 'id'
|
85
|
+
}.compact.sort
|
86
|
+
"#<#{type_with_id}#{displayable_attributes_pairs.map { |pair| " " + pair }.join}>"
|
87
|
+
end
|
88
|
+
|
89
|
+
def ==(other)
|
90
|
+
self.class === other && self.attributes == other.attributes
|
91
|
+
end
|
92
|
+
|
93
|
+
def save
|
94
|
+
resource = if saved? then update((id unless self.class.singleton), attributes) else create(attributes) end
|
95
|
+
self.attributes = resource.attributes if resource
|
96
|
+
return self
|
97
|
+
end
|
98
|
+
|
99
|
+
def saved?
|
100
|
+
respond_to?(:id) && id
|
101
|
+
end
|
102
|
+
|
103
|
+
def reload
|
104
|
+
raise ResourceNotSaved unless saved?
|
105
|
+
id = self.id
|
106
|
+
[attribute_registry, association_registry].each &:clear
|
107
|
+
self.attributes = get(id)
|
108
|
+
return self
|
109
|
+
end
|
110
|
+
|
111
|
+
def destroy
|
112
|
+
delete
|
113
|
+
return self
|
114
|
+
end
|
115
|
+
|
116
|
+
protected # for CRUDOperations
|
117
|
+
|
118
|
+
delegate :request, :to => :remote_collection
|
119
|
+
|
120
|
+
def remote_collection
|
121
|
+
@remote_collection ||=
|
122
|
+
(@basic_remote_collection || self.class.instance_eval { remote_collection }).
|
123
|
+
with_nesting(nesting).
|
124
|
+
extend(ImplicitID).set_related_resource(self)
|
125
|
+
end
|
126
|
+
|
127
|
+
def resource_class
|
128
|
+
self.class
|
129
|
+
end
|
130
|
+
|
131
|
+
protected
|
132
|
+
|
133
|
+
def association_collection_from_name(name, options={})
|
134
|
+
@association_collections ||= {}
|
135
|
+
@association_collections[name.to_sym] ||= build_association_collection_from_name(name, options)
|
136
|
+
end
|
137
|
+
|
138
|
+
private
|
139
|
+
|
140
|
+
def nesting
|
141
|
+
self.class.belongs_to.map { |res_name| [res_name, send("#{res_name}_id")] }
|
142
|
+
end
|
143
|
+
|
144
|
+
def nesting_up_to_self
|
145
|
+
nesting + [[self.class.element_name, id]]
|
146
|
+
end
|
147
|
+
|
148
|
+
def build_association_collection_from_name(name, options={})
|
149
|
+
if options[:singleton]
|
150
|
+
klass = name.to_s.camelize.constantize
|
151
|
+
klass.singleton ? klass : Class.new(klass) { self.singleton = true }
|
152
|
+
else
|
153
|
+
name.to_s.singularize.camelize.constantize
|
154
|
+
end.
|
155
|
+
instance_eval { remote_collection }.
|
156
|
+
with_nesting(nesting_up_to_self).
|
157
|
+
extend NamedRequestMethods, CRUDOperations, NestingAsImplicitAttributes
|
158
|
+
end
|
159
|
+
|
160
|
+
module NestingAsImplicitAttributes
|
161
|
+
def implicit_attributes
|
162
|
+
nesting.inject({}) { |attributes, (res_name, id)| attributes.update("#{res_name}_id" => id) }
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
module ImplicitID
|
167
|
+
def set_related_resource(resource)
|
168
|
+
@related_resource = resource
|
169
|
+
self
|
170
|
+
end
|
171
|
+
|
172
|
+
def implicit_id
|
173
|
+
@related_resource.id if @related_resource.saved?
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
@@ -0,0 +1,148 @@
|
|
1
|
+
# Copyright (c) 2006 David Heinemeier Hansson
|
2
|
+
#
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
# a copy of this software and associated documentation files (the
|
5
|
+
# "Software"), to deal in the Software without restriction, including
|
6
|
+
# without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
# distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
# permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
# the following conditions:
|
10
|
+
#
|
11
|
+
# The above copyright notice and this permission notice shall be
|
12
|
+
# included in all copies or substantial portions of the Software.
|
13
|
+
#
|
14
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
21
|
+
|
22
|
+
# Extracted from ActiveResource 79f55de9c5e3ff1f8d9e767c5af21ba31be4cfba.
|
23
|
+
module WebService
|
24
|
+
module ResponseHandling
|
25
|
+
module Exceptions
|
26
|
+
class ConnectionError < StandardError # :nodoc:
|
27
|
+
attr_reader :response
|
28
|
+
|
29
|
+
def initialize(response, message = nil)
|
30
|
+
@response = response
|
31
|
+
@message = message
|
32
|
+
end
|
33
|
+
|
34
|
+
def to_s
|
35
|
+
"Failed with #{response.code} #{response.message if response.respond_to?(:message)}"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Raised when a Timeout::Error occurs.
|
40
|
+
class TimeoutError < ConnectionError
|
41
|
+
def initialize(message)
|
42
|
+
@message = message
|
43
|
+
end
|
44
|
+
def to_s; @message ;end
|
45
|
+
end
|
46
|
+
|
47
|
+
# 3xx Redirection
|
48
|
+
class Redirection < ConnectionError # :nodoc:
|
49
|
+
def to_s; response['Location'] ? "#{super} => #{response['Location']}" : super; end
|
50
|
+
end
|
51
|
+
|
52
|
+
# 4xx Client Error
|
53
|
+
class ClientError < ConnectionError; end # :nodoc:
|
54
|
+
|
55
|
+
# 400 Bad Request
|
56
|
+
class BadRequest < ClientError; end # :nodoc
|
57
|
+
|
58
|
+
# 401 Unauthorized
|
59
|
+
class UnauthorizedAccess < ClientError; end # :nodoc
|
60
|
+
|
61
|
+
# 403 Forbidden
|
62
|
+
class ForbiddenAccess < ClientError; end # :nodoc
|
63
|
+
|
64
|
+
# 404 Not Found
|
65
|
+
class ResourceNotFound < ClientError; end # :nodoc:
|
66
|
+
|
67
|
+
# 406 Not Acceptable
|
68
|
+
class NotAcceptable < ClientError; end # :nodoc:
|
69
|
+
|
70
|
+
# 409 Conflict
|
71
|
+
class ResourceConflict < ClientError; end # :nodoc:
|
72
|
+
|
73
|
+
# 422 Unprocessable Entity
|
74
|
+
class ResourceInvalid < ClientError; end #:nodoc:
|
75
|
+
|
76
|
+
# 5xx Server Error
|
77
|
+
class ServerError < ConnectionError; end # :nodoc:
|
78
|
+
|
79
|
+
# 502 Bad Gateway
|
80
|
+
class BadGateway < ServerError; end # :nodoc:
|
81
|
+
|
82
|
+
# 503 Service Unavailable
|
83
|
+
class ServiceUnavailable < ServerError; end # :nodoc:
|
84
|
+
|
85
|
+
# 504 Gateway Timeout
|
86
|
+
class GatewayTimeout < ServerError; end # :nodoc:
|
87
|
+
|
88
|
+
# 405 Method Not Allowed
|
89
|
+
class MethodNotAllowed < ClientError # :nodoc:
|
90
|
+
def allowed_methods
|
91
|
+
@response['Allow'].split(',').map { |verb| verb.strip.downcase.to_sym }
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
include Exceptions
|
96
|
+
|
97
|
+
# Fix ConnectionError message.
|
98
|
+
ConnectionError.class_eval do
|
99
|
+
def to_s
|
100
|
+
returning "Failed" do |message|
|
101
|
+
message << " with #{response.code}" if response && response.respond_to?(:code) && response.code
|
102
|
+
message << " (#{response.message})" if response && response.respond_to?(:message) && !response.message.to_s.strip.empty?
|
103
|
+
message << ": #{@message}" if @message
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
protected
|
109
|
+
|
110
|
+
# Handles response and error codes from remote service.
|
111
|
+
def handle_response(response)
|
112
|
+
case response.code.to_i
|
113
|
+
when 301,302
|
114
|
+
raise Redirection.new(response)
|
115
|
+
when 200...400
|
116
|
+
response
|
117
|
+
when 400
|
118
|
+
raise BadRequest.new(response)
|
119
|
+
when 401
|
120
|
+
raise UnauthorizedAccess.new(response)
|
121
|
+
when 403
|
122
|
+
raise ForbiddenAccess.new(response)
|
123
|
+
when 404
|
124
|
+
raise ResourceNotFound.new(response)
|
125
|
+
when 405
|
126
|
+
raise MethodNotAllowed.new(response)
|
127
|
+
when 406
|
128
|
+
raise NotAcceptable.new(response)
|
129
|
+
when 409
|
130
|
+
raise ResourceConflict.new(response)
|
131
|
+
when 422
|
132
|
+
raise ResourceInvalid.new(response)
|
133
|
+
when 401...500
|
134
|
+
raise ClientError.new(response)
|
135
|
+
when 502
|
136
|
+
raise BadGateway.new(response)
|
137
|
+
when 503
|
138
|
+
raise ServiceUnavailable.new(response)
|
139
|
+
when 504
|
140
|
+
raise GatewayTimeout.new(response)
|
141
|
+
when 500...600
|
142
|
+
raise ServerError.new(response)
|
143
|
+
else
|
144
|
+
raise ConnectionError.new(response, "Unknown response code: #{response.code}")
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|