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