http_tools 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.
@@ -0,0 +1,169 @@
1
+ require 'strscan'
2
+
3
+ module HTTPTools
4
+
5
+ # HTTPTools::Encoding provides methods to deal with several HTTP related
6
+ # encodings including url, www-form, and chunked transfer encoding. It can be
7
+ # used as a mixin or class methods on HTTPTools::Encoding.
8
+ #
9
+ module Encoding
10
+ HEX_BIG_ENDIAN_2_BYTES = "H2".freeze
11
+ HEX_BIG_ENDIAN_REPEATING = "H*".freeze
12
+ PERCENT = "%".freeze
13
+ PLUS = "+".freeze
14
+ AMPERSAND = "&".freeze
15
+ EQUALS = "=".freeze
16
+ CHUNK_FORMAT = "%x\r\n%s\r\n".freeze
17
+
18
+ module_function
19
+
20
+ # :call-seq: Encoding.url_encode(string) -> encoded_string
21
+ #
22
+ # URL encode a string, eg "le café" becomes "le+caf%c3%a9"
23
+ #
24
+ def url_encode(string)
25
+ string.gsub(/[^a-zA-Z0-9._~-]+/) do |match|
26
+ length = match.respond_to?(:bytesize) ? match.bytesize : match.length
27
+ PERCENT + match.unpack(HEX_BIG_ENDIAN_2_BYTES * length).join(PERCENT)
28
+ end
29
+ end
30
+
31
+ # :call-seq: Encoding.url_decode(encoded_string) -> string
32
+ #
33
+ # URL decode a string, eg "le+caf%c3%a9" becomes "le café"
34
+ #
35
+ def url_decode(string)
36
+ string.tr(PLUS, SPACE).gsub(/(%[0-9a-fA-F]{2})+/) do |match|
37
+ r = [match.delete(PERCENT)].pack(HEX_BIG_ENDIAN_REPEATING)
38
+ r.respond_to?(:force_encoding) ? r.force_encoding(string.encoding) : r
39
+ end
40
+ end
41
+
42
+ # :call-seq: Encoding.www_form_encode(hash) -> string
43
+ #
44
+ # Takes a Hash and converts it to a String as if it was a HTML form being
45
+ # submitted, eg
46
+ # {"query" => "fish", "lang" => "en"} becomes "lang=en&query=fish"
47
+ #
48
+ # To get multiple key value pairs with the same key use an array as the
49
+ # value, eg
50
+ # {"lang" => ["en", "fr"]} become "lang=en&lang=fr"
51
+ #
52
+ def www_form_encode(hash)
53
+ hash.map do |key, value|
54
+ if value.respond_to?(:map) && !value.is_a?(String)
55
+ value.map {|val| www_form_encode(key => val.to_s)}.join(AMPERSAND)
56
+ else
57
+ url_encode(key.to_s) << EQUALS << url_encode(value.to_s)
58
+ end
59
+ end.join(AMPERSAND)
60
+ end
61
+
62
+ # :call-seq: Encoding.www_form_decode(string) -> hash
63
+ #
64
+ # Takes a String resulting from a HTML form being submitted, and converts it
65
+ # to a hash,
66
+ # eg "lang=en&query=fish" becomes {"query" => "fish", "lang" => "en"}
67
+ #
68
+ # Multiple key value pairs with the same key will become a single key with
69
+ # an array value, eg "lang=en&lang=fr" becomes {"lang" => ["en", "fr"]}
70
+ #
71
+ def www_form_decode(string)
72
+ string.split(AMPERSAND).inject({}) do |memo, key_value|
73
+ key, value = key_value.split(EQUALS)
74
+ key, value = url_decode(key), url_decode(value)
75
+ if memo.key?(key)
76
+ memo.merge(key => [*memo[key]].push(value))
77
+ else
78
+ memo.merge(key => value)
79
+ end
80
+ end
81
+ end
82
+
83
+ # :call-seq:
84
+ # Encoding.transfer_encoding_chunked_encode(string) -> encoded_string
85
+ #
86
+ # Returns string as a 'chunked' transfer encoding encoded string, suitable
87
+ # for a streaming response from a HTTP server, eg
88
+ # "foo" becomes "3\r\nfoo\r\n"
89
+ #
90
+ # chunked responses should be terminted with a empty chunk, eg "0\r\n",
91
+ # passing an empty string or nil will generate the empty chunk.
92
+ #
93
+ def transfer_encoding_chunked_encode(string)
94
+ if string && string.length > 0
95
+ sprintf(CHUNK_FORMAT, string.length, string)
96
+ else
97
+ "0\r\n"
98
+ end
99
+ end
100
+
101
+ # :call-seq:
102
+ # Encoding.transfer_encoding_chunked_decode(encoded_string) -> array
103
+ #
104
+ # Decoding a complete chunked response will return an array containing
105
+ # the decoded response and nil.
106
+ # Example:
107
+ # encoded_string = "3\r\nfoo\r\n3\r\nbar\r\n0\r\n"
108
+ # Encoding.transfer_encoding_chunked_decode(encoded_string)\
109
+ # => ["foobar", nil]
110
+ #
111
+ # Decoding a partial response will return an array of the response decoded
112
+ # so far, and the remainder of the encoded string.
113
+ # Example
114
+ # encoded_string = "3\r\nfoo\r\n3\r\nba"
115
+ # Encoding.transfer_encoding_chunked_decode(encoded_string)\
116
+ # => ["foo", "3\r\nba"]
117
+ #
118
+ # If the chunks are complete, but there is no empty terminating chunk, the
119
+ # second element in the array will be an empty string.
120
+ # encoded_string = "3\r\nfoo\r\n3\r\nbar"
121
+ # Encoding.transfer_encoding_chunked_decode(encoded_string)\
122
+ # => ["foobar", ""]
123
+ #
124
+ # If nothing can be decoded the first element in the array will be an empty
125
+ # string and the second the remainder
126
+ # encoded_string = "3\r\nfo"
127
+ # Encoding.transfer_encoding_chunked_decode(encoded_string)\
128
+ # => ["", "3\r\nfo"]
129
+ #
130
+ # Example use:
131
+ # include Encoding
132
+ # decoded = ""
133
+ # remainder = ""
134
+ # while remainder
135
+ # remainder << get_data
136
+ # chunk, remainder = transfer_encoding_chunked_decode(remainder)
137
+ # decoded << chunk
138
+ # end
139
+ #
140
+ def transfer_encoding_chunked_decode(scanner)
141
+ unless scanner.is_a?(StringScanner)
142
+ scanner = StringScanner.new(scanner.dup)
143
+ end
144
+ hex_chunk_length = scanner.scan(/[0-9a-fA-F]+\r?\n/)
145
+ return [nil, scanner.string] unless hex_chunk_length
146
+
147
+ chunk_length = hex_chunk_length.to_i(16)
148
+ return [nil, nil] if chunk_length == 0
149
+
150
+ chunk = scanner.rest.slice(0, chunk_length)
151
+ begin
152
+ scanner.pos += chunk_length
153
+ separator = scanner.scan(/\n|\r\n/)
154
+ rescue RangeError
155
+ end
156
+
157
+ if separator && chunk.length == chunk_length
158
+ scanner.string.replace(scanner.rest)
159
+ scanner.reset
160
+ rest, remainder = transfer_encoding_chunked_decode(scanner)
161
+ chunk << rest if rest
162
+ [chunk, remainder]
163
+ else
164
+ [nil, scanner.string]
165
+ end
166
+ end
167
+
168
+ end
169
+ end
@@ -0,0 +1,5 @@
1
+ module HTTPTools
2
+ class ParseError < StandardError; end
3
+ class EndOfMessageError < ParseError; end
4
+ class MessageIncompleteError < EndOfMessageError; end
5
+ end
@@ -0,0 +1,478 @@
1
+ require 'strscan'
2
+
3
+ module HTTPTools
4
+
5
+ # HTTPTools::Parser is a pure Ruby HTTP request & response parser with an
6
+ # evented API.
7
+ #
8
+ # The HTTP message can be fed in to the parser piece by piece as it comes over
9
+ # the wire, and the parser will call its callbacks as it works it's way
10
+ # through the message.
11
+ #
12
+ # Example:
13
+ # parser = HTTPTools::Parser.new
14
+ # parser.on(:status) {|status, message| puts "#{status} #{message}"}
15
+ # parser.on(:headers) {|headers| puts headers.inspect}
16
+ # parser.on(:body) {|body| puts body}
17
+ #
18
+ # parser << "HTTP/1.1 200 OK\r\n"
19
+ # parser << "Content-Length: 20\r\n\r\n"
20
+ # parser << "<h1>Hello world</h1>"
21
+ #
22
+ # Prints:
23
+ # 200 OK
24
+ # {"Content-Length" => "20"}
25
+ # <h1>Hello world</h1>
26
+ #
27
+ class Parser
28
+ include Encoding
29
+
30
+ KEY_TERMINATOR = ": ".freeze
31
+ CONTENT_LENGTH = "Content-Length".freeze
32
+ TRANSFER_ENCODING = "Transfer-Encoding".freeze
33
+ TRAILER = "Trailer".freeze
34
+ CHUNKED = "chunked".freeze
35
+ EVENTS = ["method", "path", "uri", "fragment", "version", "status", "key",
36
+ "value", "headers", "stream", "body", "trailers", "finished",
37
+ "error"].map {|event| event.freeze}.freeze
38
+
39
+ attr_reader :state # :nodoc:
40
+
41
+ # Force parser to expect and parse a trailer when Trailer header missing.
42
+ attr_accessor :force_trailer
43
+
44
+ # Skip parsing the body, e.g. with the response to a HEAD request.
45
+ attr_accessor :force_no_body
46
+
47
+ # :call-seq: Parser.new(delegate=nil) -> parser
48
+ #
49
+ # Create a new HTTPTools::Parser.
50
+ #
51
+ # delegate is an object that will recieve callbacks for events during
52
+ # parsing. The delegate's methods should be named on_[event name], e.g.
53
+ # on_status, on_body, etc. See #add_listener for more.
54
+ #
55
+ # Example:
56
+ # class ExampleDelegate
57
+ # def on_status(status, message)
58
+ # puts "#{status} #{message}"
59
+ # end
60
+ # end
61
+ # parser = HTTPTools::Parser.new(ExampleDelegate.new)
62
+ #
63
+ # If a callback is set for an event, it will take precedence over the
64
+ # delegate for that event.
65
+ #
66
+ def initialize(delegate=nil)
67
+ @state = :start
68
+ @buffer = StringScanner.new("")
69
+ @buffer_backup_reference = @buffer
70
+ @status = nil
71
+ @headers = {}
72
+ @last_key = nil
73
+ @content_left = nil
74
+ @body = nil
75
+ if delegate
76
+ EVENTS.each do |event|
77
+ id = "on_#{event}"
78
+ add_listener(event, delegate.method(id)) if delegate.respond_to?(id)
79
+ end
80
+ end
81
+ end
82
+
83
+ # :call-seq: parser.concat(data) -> parser
84
+ # parser << data -> parser
85
+ #
86
+ # Feed data in to the parser and trigger callbacks.
87
+ #
88
+ # Will raise HTTPTools::ParseError on error, unless a callback has been set
89
+ # for the :error event, in which case the callback will recieve the error
90
+ # insted.
91
+ #
92
+ def concat(data)
93
+ @buffer << data
94
+ @state = send(@state)
95
+ self
96
+ end
97
+ alias << concat
98
+
99
+ # :call-seq: parser.finish -> parser
100
+ #
101
+ # Used to notify the parser that the request has finished in a case where it
102
+ # can not be determined by the request itself.
103
+ #
104
+ # For example, when a server does not set a content length, and instead
105
+ # relies on closing the connection to signify the body end.
106
+ # until parser.finished?
107
+ # begin
108
+ # parser << socket.sysread(1024 * 16)
109
+ # rescue EOFError
110
+ # parser.finish
111
+ # break
112
+ # end
113
+ # end
114
+ #
115
+ # This method can not be used to interrupt parsing from within a callback.
116
+ #
117
+ # Will raise HTTPTools::MessageIncompleteError if called too early, or
118
+ # HTTPTools::EndOfMessageError if the message has already finished, unless
119
+ # a callback has been set for the :error event, in which case the callback
120
+ # will recieve the error insted.
121
+ #
122
+ def finish
123
+ if @state == :body_on_close
124
+ @body_callback.call(@body) if @body_callback
125
+ @state = end_of_message
126
+ else
127
+ raise MessageIncompleteError.new("Message ended early")
128
+ end
129
+ self
130
+ end
131
+
132
+ # :call-seq: parser.finished? -> bool
133
+ #
134
+ # Returns true when the parser has come to the end of the message, false
135
+ # otherwise.
136
+ #
137
+ # Some HTTP servers may not supply the necessary information in the response
138
+ # to determine the end of the message (e.g., no content length) and insted
139
+ # close the connection to signify the end of the message, see #finish for
140
+ # how to deal with this.
141
+ #
142
+ def finished?
143
+ @state == :end_of_message
144
+ end
145
+
146
+ # :call-seq: parser.reset -> parser
147
+ #
148
+ # Reset the parser so it can be used to process a new request.
149
+ # Callbacks/delegates will not be removed.
150
+ #
151
+ def reset
152
+ @state = :start
153
+ @buffer = @buffer_backup_reference
154
+ @buffer.string.replace("")
155
+ @buffer.reset
156
+ # @status = nil
157
+ @headers = {}
158
+ @trailer = {}
159
+ # @last_key = nil
160
+ # @content_left = nil
161
+ @body = nil
162
+ self
163
+ end
164
+
165
+ # :call-seq: parser.add_listener(event) {|arg1 [, arg2]| block} -> parser
166
+ # parser.add_listener(event, proc) -> parser
167
+ # parser.on(event) {|arg1 [, arg2]| block} -> parser
168
+ # parser.on(event, proc) -> parser
169
+ #
170
+ # Available events are :method, :path, :version, :status, :headers, :stream,
171
+ # :body, and :error.
172
+ #
173
+ # Adding a second callback for an event will overwite the existing callback
174
+ # or delegate.
175
+ #
176
+ # Events:
177
+ # [method] Supplied with one argument, the HTTP method as a String,
178
+ # e.g. "GET"
179
+ #
180
+ # [path] Supplied with two arguments, the request path as a String,
181
+ # e.g. "/example.html", and the query string as a String,
182
+ # e.g. "query=foo"
183
+ # (this callback is only called if the request uri is a path)
184
+ #
185
+ # [uri] Supplied with one argument, the request uri as a String,
186
+ # e.g. "/example.html?query=foo"
187
+ #
188
+ # [fragment] Supplied with one argument, the fragment from the request
189
+ # uri, if present
190
+ #
191
+ # [version] Supplied with one argument, the HTTP version as a String,
192
+ # e.g. "1.1"
193
+ #
194
+ # [status] Supplied with two arguments, the HTTP status code as a
195
+ # Numeric, e.g. 200, and the HTTP status message as a String,
196
+ # e.g. "OK"
197
+ #
198
+ # [headers] Supplied with one argument, the message headers as a Hash,
199
+ # e.g. {"Content-Length" => "20"}
200
+ #
201
+ # [stream] Supplied with one argument, the last chunk of body data fed
202
+ # in to the parser as a String, e.g. "<h1>Hello"
203
+ #
204
+ # [body] Supplied with one argument, the message body as a String,
205
+ # e.g. "<h1>Hello world</h1>"
206
+ #
207
+ # [finished] Supplied with one argument, any data left in the parser's
208
+ # buffer after the end of the HTTP message (likely nil, but
209
+ # possibly the start of the next message)
210
+ #
211
+ # [error] Supplied with one argument, an error encountered while
212
+ # parsing as a HTTPTools::ParseError. If a listener isn't
213
+ # registered for this event, an exception will be raised when
214
+ # an error is encountered
215
+ #
216
+ def add_listener(event, proc=nil, &block)
217
+ instance_variable_set(:"@#{event}_callback", proc || block)
218
+ self
219
+ end
220
+ alias on add_listener
221
+
222
+ private
223
+ def start
224
+ method = @buffer.scan(/[a-z]+ /i)
225
+ if method
226
+ if @method_callback
227
+ method.chop!
228
+ method.upcase!
229
+ @method_callback.call(method)
230
+ end
231
+ uri
232
+ elsif @buffer.skip(/HTTP\//i)
233
+ response_http_version
234
+ elsif @buffer.check(/[a-z]+\Z/i)
235
+ :start
236
+ else
237
+ raise ParseError.new("Protocol or method not recognised")
238
+ end
239
+ end
240
+
241
+ def uri
242
+ uri = @buffer.scan(/[a-z0-9;\/?:@&=+$,%_.!~*')(#-]*(?=( |\r\n))/i)
243
+ if uri
244
+ fragment = uri.slice!(/#[a-z0-9;\/?:@&=+$,%_.!~*')(-]+\Z/i)
245
+ if @path_callback && uri =~ /^\//i
246
+ path = uri.dup
247
+ query = path.slice!(/\?[a-z0-9;\/?:@&=+$,%_.!~*')(-]*/i)
248
+ query.slice!(0) if query
249
+ @path_callback.call(path, query)
250
+ end
251
+ @uri_callback.call(uri) if @uri_callback
252
+ if fragment && @fragment_callback
253
+ fragment.slice!(0)
254
+ @fragment_callback.call(fragment)
255
+ end
256
+ space_before_http
257
+ elsif @buffer.check(/[a-z0-9;\/?:@&=+$,%_.!~*')(#-]+\Z/i)
258
+ :uri
259
+ else
260
+ raise ParseError.new("URI or path not recognised")
261
+ end
262
+ end
263
+
264
+ def space_before_http
265
+ if @buffer.skip(/ /i)
266
+ http
267
+ elsif @buffer.skip(/\r\n/i)
268
+ key_or_newline
269
+ end
270
+ end
271
+
272
+ def http
273
+ if @buffer.skip(/HTTP\//i)
274
+ request_http_version
275
+ elsif @buffer.eos? || @buffer.check(/H(T(T(P\/?)?)?)?\Z/i)
276
+ :http
277
+ else
278
+ raise ParseError.new("Protocol not recognised")
279
+ end
280
+ end
281
+
282
+ def request_http_version
283
+ version = @buffer.scan(/[0-9]+\.[0-9]+\r\n/i)
284
+ if version
285
+ if @version_callback
286
+ version.chop!
287
+ @version_callback.call(version)
288
+ end
289
+ key_or_newline
290
+ elsif @buffer.eos? || @buffer.check(/\d+(\.(\d+\r?)?)?\Z/i)
291
+ :request_http_version
292
+ else
293
+ raise ParseError.new("Invalid version specifier")
294
+ end
295
+ end
296
+
297
+ def response_http_version
298
+ version = @buffer.scan(/[0-9]+\.[0-9]+ /i)
299
+ if version
300
+ if @version_callback
301
+ version.chop!
302
+ @version_callback.call(version)
303
+ end
304
+ status
305
+ elsif @buffer.eos? || @buffer.check(/\d+(\.(\d+)?)?\Z/i)
306
+ :response_http_version
307
+ else
308
+ raise ParseError.new("Invalid version specifier")
309
+ end
310
+ end
311
+
312
+ def status
313
+ status = @buffer.scan(/\d\d\d [a-z -]+\r?\n/i)
314
+ if status
315
+ @status = status.slice!(0, 3).to_i
316
+ @status_callback.call(@status, status.strip) if @status_callback
317
+ key_or_newline
318
+ elsif @buffer.eos? || @buffer.check(/\d(\d(\d( ([a-z]+\r?)?)?)?)?\Z/i)
319
+ :status
320
+ else
321
+ raise ParseError.new("Invalid status line")
322
+ end
323
+ end
324
+
325
+ def key_or_newline
326
+ @last_key = @buffer.scan(/[!-9;-~]+: /i)
327
+ if @last_key
328
+ @last_key.chomp!(KEY_TERMINATOR)
329
+ value
330
+ elsif @buffer.skip(/\n|\r\n/i)
331
+ @headers_callback.call(@headers) if @headers_callback
332
+ body
333
+ elsif @buffer.eos? || @buffer.check(/[!-9;-~]+:?\Z/i)
334
+ :key_or_newline
335
+ else
336
+ raise ParseError.new("Illegal character in field name")
337
+ end
338
+ end
339
+
340
+ def value
341
+ value = @buffer.scan(/[ -~]+\r?\n/i)
342
+ if value
343
+ value.chop!
344
+ @headers[@last_key] = value
345
+ key_or_newline
346
+ elsif @buffer.eos? || @buffer.check(/[ -~]+\Z/i)
347
+ :value
348
+ else
349
+ raise ParseError.new("Illegal character in field body")
350
+ end
351
+ end
352
+
353
+ def body
354
+ if @force_no_body || NO_BODY[@status]
355
+ end_of_message
356
+ elsif @buffer.eos?
357
+ :body
358
+ else
359
+ @body = "" if @body_callback
360
+ @buffer = @buffer.rest # Switch @buffer from StringScanner to String
361
+ length = @headers[CONTENT_LENGTH]
362
+ if length
363
+ @content_left = length.to_i
364
+ body_with_length
365
+ elsif @headers[TRANSFER_ENCODING] == CHUNKED
366
+ body_chunked
367
+ else
368
+ body_on_close
369
+ end
370
+ end
371
+ end
372
+
373
+ #--
374
+ # From this point on @buffer is a String, not a StringScanner.
375
+ # This is because 1. we don't need a StringScanner anymore, 2. if we
376
+ # switched to a diffrent instace variable we'd need a condition in #concat
377
+ # to feed the data in to the new instace variable, which would slow us down.
378
+ #++
379
+
380
+ def body_with_length
381
+ if @buffer.length > 0
382
+ chunk = @buffer.slice!(0, @content_left)
383
+ @stream_callback.call(chunk) if @stream_callback
384
+ @body << chunk if @body_callback
385
+ @content_left -= chunk.length
386
+ if @content_left < 1
387
+ @body_callback.call(@body) if @body_callback
388
+ end_of_message
389
+ else
390
+ :body_with_length
391
+ end
392
+ else
393
+ :body_with_length
394
+ end
395
+ end
396
+
397
+ def body_chunked
398
+ decoded, remainder = transfer_encoding_chunked_decode(@buffer)
399
+ if decoded
400
+ @stream_callback.call(decoded) if @stream_callback
401
+ @body << decoded if @body_callback
402
+ end
403
+ if remainder
404
+ @buffer = remainder
405
+ :body_chunked
406
+ else
407
+ @buffer.slice!(/.*0\r\n/m)
408
+ @body_callback.call(@body) if @body_callback
409
+ if @headers[TRAILER] || @force_trailer
410
+ @trailer = {}
411
+ # @buffer switches back to a StringScanner for the trailer.
412
+ @buffer_backup_reference.string.replace(@buffer)
413
+ @buffer_backup_reference.reset
414
+ @buffer = @buffer_backup_reference
415
+ trailer_key_or_newline
416
+ else
417
+ end_of_message
418
+ end
419
+ end
420
+ end
421
+
422
+ def body_on_close
423
+ @stream_callback.call(@buffer) if @stream_callback
424
+ @body << @buffer if @body_callback
425
+ @buffer = ""
426
+ :body_on_close
427
+ end
428
+
429
+ #--
430
+ # @buffer switches back to a StringScanner for the trailer.
431
+ #++
432
+
433
+ def trailer_key_or_newline
434
+ if @last_key = @buffer.scan(/[!-9;-~]+: /i)
435
+ @last_key.chomp!(KEY_TERMINATOR)
436
+ trailer_value
437
+ elsif @buffer.skip(/\n|\r\n/i)
438
+ @trailer_callback.call(@trailer) if @trailer_callback
439
+ end_of_message
440
+ elsif @buffer.eos? || @buffer.check(/[!-9;-~]+:?\Z/i)
441
+ :trailer_key_or_newline
442
+ else
443
+ raise ParseError.new("Illegal character in field name")
444
+ end
445
+ end
446
+
447
+ def trailer_value
448
+ value = @buffer.scan(/[ -~]+\r?\n/i)
449
+ if value
450
+ value.chop!
451
+ @trailer[@last_key] = value
452
+ trailer_key_or_newline
453
+ elsif @buffer.eos? || @buffer.check(/[ -~]+\Z/i)
454
+ :trailer_value
455
+ else
456
+ raise ParseError.new("Illegal character in field body")
457
+ end
458
+ end
459
+
460
+ def end_of_message
461
+ raise EndOfMessageError.new("Message ended") if @state == :end_of_message
462
+ remainder = @buffer.respond_to?(:rest) ? @buffer.rest : @buffer
463
+ if @finished_callback
464
+ @finished_callback.call((remainder if remainder.length > 0))
465
+ end
466
+ :end_of_message
467
+ end
468
+
469
+ def raise(*args)
470
+ @state = :error
471
+ super unless @error_callback
472
+ @error_callback.call(args.first)
473
+ :error
474
+ end
475
+ alias error raise
476
+
477
+ end
478
+ end