x-streams 1.0.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.
- checksums.yaml +7 -0
- data/.yardopts +8 -0
- data/CHANGELOG.md +99 -0
- data/LICENSE.txt +21 -0
- data/README.md +61 -0
- data/lib/x/streams/api.rb +41 -0
- data/lib/x/streams/callback_error.rb +53 -0
- data/lib/x/streams/error.rb +34 -0
- data/lib/x/streams/reconnect_handler.rb +322 -0
- data/lib/x/streams/rules_rejected.rb +84 -0
- data/lib/x/streams/stopper.rb +366 -0
- data/lib/x/streams/stream_error.rb +96 -0
- data/lib/x/streams/stream_parser.rb +182 -0
- data/lib/x/streams/stream_rule.rb +182 -0
- data/lib/x/streams/stream_rules.rb +153 -0
- data/lib/x/streams/streaming_client.rb +544 -0
- data/lib/x/streams/validator.rb +119 -0
- data/lib/x/streams/version.rb +24 -0
- data/lib/x/streams.rb +9 -0
- data/sig/manifest.yaml +6 -0
- data/sig/x-streams.rbs +87 -0
- metadata +88 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X
|
|
4
|
+
module Streams
|
|
5
|
+
# Stops the streams a streaming client runs, from any thread, for good
|
|
6
|
+
#
|
|
7
|
+
# A stream waits on the API for most of its life, so its block, which runs only when an object arrives, cannot stop
|
|
8
|
+
# one that delivers nothing. Each stream runs in a thread the stopper knows, and stop raises Stopped in each, which
|
|
9
|
+
# is delivered as the stream next blocks, mostly as it waits on the API, ending a read, a connection, or a wait to
|
|
10
|
+
# reconnect, but never in the block of the stream, the on_response or on_reconnect of its client, or the
|
|
11
|
+
# save_tokens a refresh is reported to, which guard runs, so that what each does is never cut short. A stopper
|
|
12
|
+
# that was stopped stays stopped, so a stream that had not begun when stop was called, as one a thread started
|
|
13
|
+
# just before it may not have, never begins.
|
|
14
|
+
#
|
|
15
|
+
# Internal to x-streams: StreamingClient#stop stops the streams it runs with it.
|
|
16
|
+
#
|
|
17
|
+
# @api private
|
|
18
|
+
class Stopper
|
|
19
|
+
# Raised in the thread of a stream that stop stops, or by a run that reads the stopper was stopped; each stopper
|
|
20
|
+
# raises a subclass of its own, which only its run rescues to return nil
|
|
21
|
+
#
|
|
22
|
+
# It is not a StandardError, as Timeout::ExitException is not, so that a callback the stream runs where a stop is
|
|
23
|
+
# delivered, as an object_class whose from_response looks something up or a load_tokens that reads a store, cannot
|
|
24
|
+
# rescue it, and raise an error of its own or let the stream go on, rather than return nil.
|
|
25
|
+
class Stopped < Exception; end # rubocop:disable Lint/InheritException
|
|
26
|
+
private_constant :Stopped
|
|
27
|
+
|
|
28
|
+
# A stream a stopper runs: the thread it runs in, whether it runs in a fiber a Fiber scheduler runs, and whether a
|
|
29
|
+
# guarded block of it is running
|
|
30
|
+
class Stream
|
|
31
|
+
# The thread the stream runs in
|
|
32
|
+
# @api private
|
|
33
|
+
# @return [Thread] the thread
|
|
34
|
+
attr_reader :thread
|
|
35
|
+
|
|
36
|
+
# Whether a Fiber scheduler runs the non-blocking fiber of the stream
|
|
37
|
+
#
|
|
38
|
+
# It does for a stream in an Async task.
|
|
39
|
+
#
|
|
40
|
+
# Such a stream waits on the API in the scheduler, not in a read of its own, so a stop raised in its thread
|
|
41
|
+
# would end the scheduler rather than the stream; stop raises in no such stream, which is stopped once its
|
|
42
|
+
# block returns.
|
|
43
|
+
#
|
|
44
|
+
# @api private
|
|
45
|
+
# @return [Boolean] true if a Fiber scheduler runs the fiber of the stream
|
|
46
|
+
attr_reader :scheduled
|
|
47
|
+
|
|
48
|
+
# Whether a guarded block of the stream is running
|
|
49
|
+
# @api private
|
|
50
|
+
# @return [Boolean] true while a guarded block of the stream runs
|
|
51
|
+
attr_accessor :guarded
|
|
52
|
+
|
|
53
|
+
# Initialize a stream that runs in the current fiber
|
|
54
|
+
#
|
|
55
|
+
# No guarded block of it is running.
|
|
56
|
+
#
|
|
57
|
+
# @api private
|
|
58
|
+
# @return [Stream] a new stream
|
|
59
|
+
def initialize
|
|
60
|
+
@thread = Thread.current
|
|
61
|
+
@scheduled = !Fiber.blocking? && !Fiber.scheduler.nil?
|
|
62
|
+
@guarded = false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Whether stop raises in the stream
|
|
66
|
+
#
|
|
67
|
+
# It does if no guarded block of the stream runs, and no Fiber scheduler runs it.
|
|
68
|
+
#
|
|
69
|
+
# @api private
|
|
70
|
+
# @return [Boolean] true if stop raises in the thread of the stream
|
|
71
|
+
def interruptible? = !guarded && !scheduled
|
|
72
|
+
end
|
|
73
|
+
private_constant :Stream
|
|
74
|
+
|
|
75
|
+
# The key of the fiber-local variable that holds the stopper and the stream that run in the current fiber
|
|
76
|
+
CURRENT = :__x_streams_stopper_current__
|
|
77
|
+
private_constant :CURRENT
|
|
78
|
+
|
|
79
|
+
# Run a block that stop does not cut short, deferring a stop until it returns
|
|
80
|
+
#
|
|
81
|
+
# No stop is held back in the thread while a guarded block of a stream runs, and stop raises in no stream whose
|
|
82
|
+
# guarded block runs, so a stream whose block is held, as in a Fiber an Enumerator iterates, is not stopped in
|
|
83
|
+
# the place of another stream of the same thread. A stop held back as the block began is discarded, and once the
|
|
84
|
+
# block returns, a stream whose stopper was stopped is stopped the next time it waits on the API, as one stop
|
|
85
|
+
# raises in is. A block that ends by raising, throwing, or breaking ends the stream as it does, since no stop was
|
|
86
|
+
# held back to end the stream in its place as it unwinds.
|
|
87
|
+
#
|
|
88
|
+
# @api private
|
|
89
|
+
# @yield the block
|
|
90
|
+
# @return [Object] what the block returns
|
|
91
|
+
# @example Pass an object to the block of a stream
|
|
92
|
+
# X::Streams::Stopper.guard { block.call(post) }
|
|
93
|
+
def self.guard(&)
|
|
94
|
+
stopper, stream = Thread.current[CURRENT]
|
|
95
|
+
return Thread.handle_interrupt(Stopped => :never, &) unless stream
|
|
96
|
+
|
|
97
|
+
result = guarding_stream(stream, &)
|
|
98
|
+
stopper.__send__(:stop_returned, stream)
|
|
99
|
+
result
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Run a block with a stream marked as running a guarded block
|
|
103
|
+
#
|
|
104
|
+
# The mark the stream had is restored after. The stream is marked before a stop held back as the block begins
|
|
105
|
+
# is discarded, so that stop, which raises in no stream so marked, raises in it either before the discard or
|
|
106
|
+
# not at all, and none is held back while the block runs. The stream is stopped once the block returns.
|
|
107
|
+
#
|
|
108
|
+
# @api private
|
|
109
|
+
# @param stream [Stream] the stream
|
|
110
|
+
# @yield the block
|
|
111
|
+
# @return [Object] what the block returns
|
|
112
|
+
def self.guarding_stream(stream)
|
|
113
|
+
outer = stream.guarded
|
|
114
|
+
begin
|
|
115
|
+
stream.guarded = true
|
|
116
|
+
discard_stop
|
|
117
|
+
yield
|
|
118
|
+
ensure
|
|
119
|
+
stream.guarded = outer
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
private_class_method :guarding_stream
|
|
123
|
+
|
|
124
|
+
# Discard every stop held back in the current thread
|
|
125
|
+
#
|
|
126
|
+
# Each call of stop raises in a stream that is reading, and Ruby holds each back on its own, so a stream stopped
|
|
127
|
+
# more than once holds back as many; each is discarded in turn until none is left, so that none cuts a block short
|
|
128
|
+
# or reaches the caller once the stream returns.
|
|
129
|
+
#
|
|
130
|
+
# @api private
|
|
131
|
+
# @return [nil]
|
|
132
|
+
def self.discard_stop
|
|
133
|
+
Thread.handle_interrupt(Stopped => :immediate) {}
|
|
134
|
+
rescue Stopped
|
|
135
|
+
retry
|
|
136
|
+
end
|
|
137
|
+
private_class_method :discard_stop
|
|
138
|
+
|
|
139
|
+
# A callable that calls another, then stops its stream if it was stopped
|
|
140
|
+
#
|
|
141
|
+
# A stream calls it for each keep-alive, so a stream a Fiber scheduler runs, which stop raises in no read of,
|
|
142
|
+
# is stopped at the next keep-alive X sends, within 20 seconds, though it delivers no object.
|
|
143
|
+
#
|
|
144
|
+
# @api private
|
|
145
|
+
# @param callable [#call] the callable
|
|
146
|
+
# @return [Proc] the callable that calls it and then stops the stream
|
|
147
|
+
# @example Stop a stream at a keep-alive
|
|
148
|
+
# X::Streams::Stopper.checking(alive)
|
|
149
|
+
def self.checking(callable)
|
|
150
|
+
lambda do
|
|
151
|
+
callable.call
|
|
152
|
+
stopper, stream = Thread.current[CURRENT]
|
|
153
|
+
stopper&.__send__(:stop_returned, stream)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The most seconds a stream a Fiber scheduler runs waits to reconnect before it checks whether it was stopped
|
|
158
|
+
PAUSE = 1
|
|
159
|
+
private_constant :PAUSE
|
|
160
|
+
|
|
161
|
+
# Wait to reconnect, yielding the seconds to sleep, and stop the stream if stopped
|
|
162
|
+
#
|
|
163
|
+
# A stream a Fiber scheduler runs, which stop raises in no wait of, waits a second at a time, and is stopped
|
|
164
|
+
# before each second and before it reconnects, so a stream that cannot connect, or that X answers with an error,
|
|
165
|
+
# is stopped within a second, though it delivers no object and reads no keep-alive. Any other stream waits the
|
|
166
|
+
# seconds at once, which stop cuts short.
|
|
167
|
+
#
|
|
168
|
+
# @api private
|
|
169
|
+
# @param seconds [Integer, Float] the seconds to wait
|
|
170
|
+
# @yieldparam seconds [Integer, Float] the seconds to sleep
|
|
171
|
+
# @return [void]
|
|
172
|
+
# @example Wait to reconnect
|
|
173
|
+
# X::Streams::Stopper.pausing(5) { |seconds| sleep(seconds) }
|
|
174
|
+
def self.pausing(seconds)
|
|
175
|
+
stopper, stream = Thread.current[CURRENT]
|
|
176
|
+
return yield(seconds) unless stream&.scheduled
|
|
177
|
+
|
|
178
|
+
while seconds.positive?
|
|
179
|
+
stopper.__send__(:stop_returned, stream)
|
|
180
|
+
yield [seconds, PAUSE].min
|
|
181
|
+
seconds -= PAUSE
|
|
182
|
+
end
|
|
183
|
+
stopper.__send__(:stop_returned, stream)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# A callable that calls another with guard, or nil for no callable
|
|
187
|
+
#
|
|
188
|
+
# stop does not cut short the callable it calls, as it does not the block of guard.
|
|
189
|
+
#
|
|
190
|
+
# @api private
|
|
191
|
+
# @param callable [#call, nil] the callable
|
|
192
|
+
# @return [Proc, nil] the callable that guards it, or nil if it is nil
|
|
193
|
+
# @example Guard the on_response of a client
|
|
194
|
+
# X::Streams::Stopper.guarding(client.on_response)
|
|
195
|
+
def self.guarding(callable) = callable && ->(*arguments) { guard { callable.call(*arguments) } }
|
|
196
|
+
|
|
197
|
+
# Initialize a stopper that knows no stream, and was not stopped
|
|
198
|
+
#
|
|
199
|
+
# @api private
|
|
200
|
+
# @return [Stopper] a new stopper
|
|
201
|
+
def initialize
|
|
202
|
+
@streams = [] #: Array[Stream]
|
|
203
|
+
@lock = Mutex.new
|
|
204
|
+
@stopped = false
|
|
205
|
+
@stop = Class.new(Stopped) #: singleton(Stopped)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Whether the stopper was stopped
|
|
209
|
+
#
|
|
210
|
+
# @api private
|
|
211
|
+
# @return [Boolean] true once stop was called
|
|
212
|
+
# @example Check whether the streams of a streaming client were stopped
|
|
213
|
+
# stopper.stopped? # => false
|
|
214
|
+
def stopped? = @stopped
|
|
215
|
+
|
|
216
|
+
# Run a stream that stop stops, unless the stopper was stopped
|
|
217
|
+
#
|
|
218
|
+
# The stream is known to stop while it runs alone, under the lock stop raises under, so stop raises in no
|
|
219
|
+
# thread that is not running a stream. Whether the stopper was stopped is read under that lock too, and stop
|
|
220
|
+
# marks it before it takes the lock, so either the stream is known by the time stop raises, or it reads that
|
|
221
|
+
# the stopper was stopped and never begins. A stop that arrives as the stream ends is discarded once the stream
|
|
222
|
+
# is no longer known to stop, so it never reaches the caller. Only a stop of this stopper is rescued, so one
|
|
223
|
+
# of another, as for a stream that runs in the block of this one, ends the stream it stops.
|
|
224
|
+
#
|
|
225
|
+
# @api private
|
|
226
|
+
# @yield runs the stream
|
|
227
|
+
# @return [Object, nil] what the stream returned, or nil if stop stopped it, or was called before it began
|
|
228
|
+
# @example Run a stream that stop stops
|
|
229
|
+
# stopper.run { reconnect_handler.handle(consumer) { |deliver, alive| read(deliver, alive) } }
|
|
230
|
+
def run(&)
|
|
231
|
+
Thread.handle_interrupt(Stopped => :never) do
|
|
232
|
+
stream = Stream.new
|
|
233
|
+
raise @stop unless @lock.synchronize { @streams << stream unless stopped? }
|
|
234
|
+
|
|
235
|
+
running(stream, &)
|
|
236
|
+
end
|
|
237
|
+
rescue @stop
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Stop every stream the stopper runs, and every one it is asked to run later
|
|
242
|
+
#
|
|
243
|
+
# It may be called from the trap of a signal, where no lock can be waited for, and where the thread that holds
|
|
244
|
+
# the lock may be the one the trap interrupted, so it marks the stopper stopped without the lock, and raises in
|
|
245
|
+
# the streams under the lock only if it takes it at once. Otherwise a thread of its own waits for the lock and
|
|
246
|
+
# raises in them, which it does not wait for, since the thread that holds the lock may be waiting for it to
|
|
247
|
+
# return. A thread of its own raises in them too when it is called in the thread of a stream that is reading,
|
|
248
|
+
# as from a trap that interrupts a stream on the main thread as it reads, since Stopped raised in the thread
|
|
249
|
+
# that raises it is held back until the read the trap interrupted returns, which may be never. It raises in no
|
|
250
|
+
# stream whose guarded block is running, which guard stops as the block returns.
|
|
251
|
+
#
|
|
252
|
+
# @api private
|
|
253
|
+
# @return [nil]
|
|
254
|
+
# @example Stop the streams of a streaming client
|
|
255
|
+
# stopper.stop # => nil
|
|
256
|
+
def stop
|
|
257
|
+
@stopped = true
|
|
258
|
+
(!reading_here? && lock_at_once) ? interrupt_and_unlock : Thread.new { @lock.synchronize { interrupt } }
|
|
259
|
+
nil
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
private
|
|
263
|
+
|
|
264
|
+
# Run a stream the stopper knows as the stream of the current fiber
|
|
265
|
+
#
|
|
266
|
+
# The save_tokens of a refresh the stream makes is run under guard too, as its block is, so that a stop waits for
|
|
267
|
+
# it to store the tokens rather than cut it short and leave the store with a refresh token X no longer accepts.
|
|
268
|
+
#
|
|
269
|
+
# @api private
|
|
270
|
+
# @param stream [Stream] the stream
|
|
271
|
+
# @yield runs the stream
|
|
272
|
+
# @return [Object] what the stream returned
|
|
273
|
+
def running(stream)
|
|
274
|
+
outer = Thread.current[CURRENT] #: [Stopper, Stream]?
|
|
275
|
+
begin
|
|
276
|
+
Thread.current[CURRENT] = [self, stream]
|
|
277
|
+
RefreshReportGuard.around(self.class.public_method(:guard)) { Thread.handle_interrupt(Stopped => :on_blocking) { yield } }
|
|
278
|
+
ensure
|
|
279
|
+
Thread.current[CURRENT] = outer
|
|
280
|
+
@lock.synchronize { @streams.delete_if { |known| known.equal?(stream) } }
|
|
281
|
+
self.class.__send__(:discard_stop)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Hold a stop back for a stream whose guarded block returned once it was stopped
|
|
286
|
+
#
|
|
287
|
+
# A stream a Fiber scheduler runs is stopped at once instead, since a stop held back in its thread would be
|
|
288
|
+
# delivered as the scheduler waits, ending the scheduler rather than the stream. Each keep-alive read before
|
|
289
|
+
# the stream next waits on the API holds back one more, which the stream discards with the rest as it ends.
|
|
290
|
+
#
|
|
291
|
+
# @api private
|
|
292
|
+
# @param stream [Stream] the stream
|
|
293
|
+
# @return [void]
|
|
294
|
+
# @raise [Stopped] if the stopper was stopped and a Fiber scheduler runs the stream
|
|
295
|
+
def stop_returned(stream)
|
|
296
|
+
return unless stopped? && !stream.guarded
|
|
297
|
+
raise @stop if stream.scheduled
|
|
298
|
+
|
|
299
|
+
Thread.current.raise(@stop)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Whether a stream the stopper runs is reading in the current thread
|
|
303
|
+
#
|
|
304
|
+
# It is as a trap that interrupts the read of a stream on its own thread runs.
|
|
305
|
+
#
|
|
306
|
+
# @api private
|
|
307
|
+
# @return [Boolean] true if a stream runs in the current thread and no guarded block of it is running
|
|
308
|
+
def reading_here? = @streams.any? { |stream| stream.thread.equal?(Thread.current) && stream.interruptible? }
|
|
309
|
+
|
|
310
|
+
# Take the lock if no thread holds it, without waiting for it
|
|
311
|
+
#
|
|
312
|
+
# @api private
|
|
313
|
+
# @return [Boolean] true if it took the lock, or false if a thread holds it, or it cannot be taken here
|
|
314
|
+
def lock_at_once
|
|
315
|
+
@lock.try_lock
|
|
316
|
+
rescue ThreadError
|
|
317
|
+
false
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Raise Stopped in each stream the stopper runs, and release the lock this took
|
|
321
|
+
# @api private
|
|
322
|
+
# @return [void]
|
|
323
|
+
def interrupt_and_unlock
|
|
324
|
+
interrupt
|
|
325
|
+
ensure
|
|
326
|
+
@lock.unlock
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Raise Stopped in each stream that is interruptible, under the lock
|
|
330
|
+
# @api private
|
|
331
|
+
# @return [void]
|
|
332
|
+
def interrupt = @streams.each { |stream| stream.thread.raise(@stop) if stream.interruptible? }
|
|
333
|
+
end
|
|
334
|
+
private_constant :Stopper
|
|
335
|
+
|
|
336
|
+
# Runs the save_tokens of a refresh a stream makes inside the guard of the stream
|
|
337
|
+
#
|
|
338
|
+
# x-core runs the callables a refresh reports to inside the callable under this fiber-local key, if one is set,
|
|
339
|
+
# so that a stop waits for save_tokens to store the tokens rather than cut it short and leave the store with a
|
|
340
|
+
# refresh token X no longer accepts.
|
|
341
|
+
#
|
|
342
|
+
# @api private
|
|
343
|
+
module RefreshReportGuard
|
|
344
|
+
# The fiber-local key x-core reads the guard from
|
|
345
|
+
KEY = :x_core_refresh_report_guard
|
|
346
|
+
|
|
347
|
+
# Run a block with a guard set for its refreshes, then set the one before again
|
|
348
|
+
# @api private
|
|
349
|
+
# @param guard [#call] the guard, which is passed a block to run
|
|
350
|
+
# @yield runs the stream
|
|
351
|
+
# @return [Object] what the block returns
|
|
352
|
+
# @example Guard the refreshes of a stream
|
|
353
|
+
# RefreshReportGuard.around(Stopper.method(:guard)) { stream }
|
|
354
|
+
def self.around(guard)
|
|
355
|
+
outer = Thread.current[KEY]
|
|
356
|
+
begin
|
|
357
|
+
Thread.current[KEY] = guard
|
|
358
|
+
yield
|
|
359
|
+
ensure
|
|
360
|
+
Thread.current[KEY] = outer
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
private_constant :RefreshReportGuard
|
|
365
|
+
end
|
|
366
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "x/core"
|
|
4
|
+
require_relative "error"
|
|
5
|
+
|
|
6
|
+
module X
|
|
7
|
+
module Streams
|
|
8
|
+
# Raised for a line of a stream that holds errors and no data
|
|
9
|
+
#
|
|
10
|
+
# A stream sends the problems it has in a line of their own, such as the operational-disconnect X sends before it
|
|
11
|
+
# closes a stream. The line is not an object the stream delivers, so the stream raises this error in place of
|
|
12
|
+
# passing it to its block, whether the objects are Hashes or are built by an object_class.
|
|
13
|
+
#
|
|
14
|
+
# A stream reconnects after a line that holds operational-disconnects alone, as it does after a connection that
|
|
15
|
+
# dropped, and raises this error once it has no reconnects left. After any other problems it stops, and the error
|
|
16
|
+
# reaches the caller, who decides whether to open the stream again. The message names the request, and each
|
|
17
|
+
# problem, and {#problems} holds them, as X::HTTPError#problems holds those of a response the API refused.
|
|
18
|
+
# {#http_method} and {#uri} are the request of the stream.
|
|
19
|
+
#
|
|
20
|
+
# @api public
|
|
21
|
+
# @example Report the problems that stopped a stream
|
|
22
|
+
# begin
|
|
23
|
+
# client.streaming.stream("tweets/search/stream") { |post| handle(post) }
|
|
24
|
+
# rescue X::StreamError => e
|
|
25
|
+
# warn e.problems.map(&:title).join(", ")
|
|
26
|
+
# end
|
|
27
|
+
class ::X::StreamError < Streams::Error
|
|
28
|
+
# The HTTP method the request of the stream was sent with
|
|
29
|
+
#
|
|
30
|
+
# @api public
|
|
31
|
+
# @return [Symbol, nil] the method, as :get, or nil for an error built without one, such as one a test built
|
|
32
|
+
# @example Read the method of the stream
|
|
33
|
+
# error.http_method # => :get
|
|
34
|
+
attr_reader :http_method
|
|
35
|
+
|
|
36
|
+
# The URI the request of the stream was sent to
|
|
37
|
+
#
|
|
38
|
+
# @api public
|
|
39
|
+
# @return [URI::Generic, nil] the URI, or nil for an error built without one
|
|
40
|
+
# @example Read the path of the stream
|
|
41
|
+
# error.uri.path # => "/2/tweets/search/stream"
|
|
42
|
+
attr_reader :uri
|
|
43
|
+
|
|
44
|
+
# The problems the line of the stream held
|
|
45
|
+
#
|
|
46
|
+
# @api public
|
|
47
|
+
# @return [Array<Problem>] the problems, frozen, in the order the line held them
|
|
48
|
+
# @example Read the title of each problem
|
|
49
|
+
# error.problems.map(&:title) # => ["operational-disconnect"]
|
|
50
|
+
attr_reader :problems
|
|
51
|
+
|
|
52
|
+
# Initialize a new StreamError
|
|
53
|
+
#
|
|
54
|
+
# Public, so that code that rescues a StreamError can be tested with one built by hand, as StreamParser builds one
|
|
55
|
+
# for a line of a stream, and raised with a message alone, as any exception is. The message is the one given, or
|
|
56
|
+
# else the title and detail of each problem, and names the request, when given its method and URI, as the errors
|
|
57
|
+
# of x-core name the request that raised them.
|
|
58
|
+
#
|
|
59
|
+
# @api public
|
|
60
|
+
# @param message [String, nil] the message, or nil for the one the problems give
|
|
61
|
+
# @param problems [Array<Problem>] the problems the line held
|
|
62
|
+
# @param http_method [Symbol, String, nil] the method of the request of the stream, in any case
|
|
63
|
+
# @param uri [URI::Generic, nil] the URI of the request of the stream
|
|
64
|
+
# @return [StreamError] a new instance
|
|
65
|
+
# @example Create an error
|
|
66
|
+
# error = X::StreamError.new(problems: X::Problem.all_from(body), http_method: :get, uri: stream_uri)
|
|
67
|
+
# @example Raise the error with a message alone, as a test stub may
|
|
68
|
+
# raise X::StreamError, "The stream dropped"
|
|
69
|
+
def initialize(message = nil, problems: [], http_method: nil, uri: nil)
|
|
70
|
+
@problems = problems.dup.freeze
|
|
71
|
+
@http_method = http_method&.downcase&.to_sym
|
|
72
|
+
@uri = uri
|
|
73
|
+
super(naming_request(message || describe(problems)))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# The message, led by the method and path of the request it names, if any
|
|
79
|
+
#
|
|
80
|
+
# It names the request as an error of x-core does, as "GET /2/tweets/search/stream: operational-disconnect". An
|
|
81
|
+
# error that says nothing of what went wrong names no request, so that its message is the name of its class, as
|
|
82
|
+
# that of any exception raised with no message is.
|
|
83
|
+
#
|
|
84
|
+
# @api private
|
|
85
|
+
# @param message [String, nil] what went wrong, or nil for nothing
|
|
86
|
+
# @return [String, nil] the message, or nil for none
|
|
87
|
+
def naming_request(message)
|
|
88
|
+
http_method, uri = @http_method, @uri
|
|
89
|
+
return message unless http_method && uri && message
|
|
90
|
+
|
|
91
|
+
path = uri.path #: String
|
|
92
|
+
"#{http_method.upcase} #{path.empty? ? "/" : path}: #{message}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "x/core"
|
|
5
|
+
require_relative "callback_error"
|
|
6
|
+
require_relative "stream_error"
|
|
7
|
+
|
|
8
|
+
module X
|
|
9
|
+
module Streams
|
|
10
|
+
# Reads the lines of a stream the API answered, and decodes each into the object it delivers
|
|
11
|
+
#
|
|
12
|
+
# Internal to x-streams: StreamingClient reads streams with it.
|
|
13
|
+
#
|
|
14
|
+
# @api private
|
|
15
|
+
class StreamParser
|
|
16
|
+
# Line delimiter for streaming responses
|
|
17
|
+
LINE_DELIMITER = "\r\n"
|
|
18
|
+
|
|
19
|
+
# Read a stream the API answered, and yield each object it delivers
|
|
20
|
+
#
|
|
21
|
+
# The request of the stream, which its errors name, is the one the response answers, a GET of its URI.
|
|
22
|
+
#
|
|
23
|
+
# @api private
|
|
24
|
+
# @param response [StreamResponse] the successful response of the stream, whose body is not yet read
|
|
25
|
+
# @param array_class [Class] the class for parsing JSON arrays
|
|
26
|
+
# @param object_class [Class, #from_response] the class for parsing JSON objects, or one that builds each object
|
|
27
|
+
# from the whole line
|
|
28
|
+
# @param client [Client] the client of the stream, which from_response is passed
|
|
29
|
+
# @param on_line [#call] a callable passed each line of JSON before it is decoded
|
|
30
|
+
# @param on_keep_alive [#call] a callable called for each keep-alive, the empty line X sends a quiet stream
|
|
31
|
+
# @yield [Object] each decoded JSON document from the stream
|
|
32
|
+
# @return [void]
|
|
33
|
+
# @raise [InvalidResponse] if a line of the stream is not JSON, which the error holds as its body
|
|
34
|
+
# @raise [StreamError] if a line holds errors and no data
|
|
35
|
+
# @raise [CallbackError] if on_line, or the object_class that builds each object, raises, so that the error is
|
|
36
|
+
# not taken for one of the stream
|
|
37
|
+
# @example Process a streaming response
|
|
38
|
+
# parser.process(response:, array_class: Array, object_class: Hash, client:, on_line: ->(_) {}, on_keep_alive: -> {}) { |json| puts json }
|
|
39
|
+
def process(response:, array_class:, object_class:, client:, on_line:, on_keep_alive:, &block)
|
|
40
|
+
decode = lambda do |line|
|
|
41
|
+
tagging_callback_errors { on_line.call(line) }
|
|
42
|
+
body = parse_line(line, response:)
|
|
43
|
+
tagging_callback_errors { build(body, array_class:, object_class:, client:) }
|
|
44
|
+
end
|
|
45
|
+
read_lines(response:, decode:, on_keep_alive:, &block)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Parse a line of the stream, raising for one not JSON or holding errors alone
|
|
51
|
+
#
|
|
52
|
+
# A line that holds errors and no data, such as the operational-disconnect X sends before it closes a stream,
|
|
53
|
+
# is not an object the stream delivers, so it raises StreamError, which holds the problems, whatever the
|
|
54
|
+
# objects are built as, rather than reach the block as a Hash that holds no data, or build nothing of it with
|
|
55
|
+
# an object_class that responds to from_response.
|
|
56
|
+
#
|
|
57
|
+
# Only this parse is the stream's own, so only a line it cannot parse raises InvalidResponse, which a stream
|
|
58
|
+
# reconnects after: a JSON::ParserError of on_line or of the object_class is theirs, and stops the stream.
|
|
59
|
+
#
|
|
60
|
+
# @api private
|
|
61
|
+
# @param line [String] the line, tagged UTF-8
|
|
62
|
+
# @param response [StreamResponse] the response of the stream, whose request the errors name
|
|
63
|
+
# @return [Object] the parsed line
|
|
64
|
+
# @raise [InvalidResponse] if the line is not JSON, which the error holds as its body
|
|
65
|
+
# @raise [StreamError] if the line holds errors and no data
|
|
66
|
+
def parse_line(line, response:)
|
|
67
|
+
body = JSON.parse(line)
|
|
68
|
+
problems = (Hash === body && !body.key?("data")) ? Problem.all_from(body) : [] #: Array[Problem]
|
|
69
|
+
raise StreamError.new(problems:, http_method: :get, uri: response.uri) unless problems.empty?
|
|
70
|
+
|
|
71
|
+
body
|
|
72
|
+
rescue JSON::ParserError
|
|
73
|
+
raise InvalidResponse.new(http_response: response.http_response, body: line, http_method: :get, uri: response.uri)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Build the object a line delivers, as the body of a request is decoded
|
|
77
|
+
#
|
|
78
|
+
# The line was parsed once already, into Hashes and Arrays, so it is built from what that parse gave rather than
|
|
79
|
+
# parsed again.
|
|
80
|
+
#
|
|
81
|
+
# @api private
|
|
82
|
+
# @param body [Object] the line, parsed
|
|
83
|
+
# @param array_class [Class] the class for parsing JSON arrays
|
|
84
|
+
# @param object_class [Class, #from_response] the class for parsing JSON objects, or one that builds each object
|
|
85
|
+
# from the whole line
|
|
86
|
+
# @param client [Client] the client of the stream, which from_response is passed
|
|
87
|
+
# @return [Object] the object
|
|
88
|
+
def build(body, array_class:, object_class:, client:)
|
|
89
|
+
return object_class.from_response(body, client:) if object_class.respond_to?(:from_response)
|
|
90
|
+
|
|
91
|
+
rebuild(body, array_class:, object_class:)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Build a parsed value into the array_class and object_class given
|
|
95
|
+
#
|
|
96
|
+
# Each is built as JSON.parse builds it: an object into a new object_class, which is given each of its pairs with
|
|
97
|
+
# []=, in order, and an array into a new array_class, which is given each of its elements with <<, in order,
|
|
98
|
+
# each built in turn. Any other value is what the parse gave.
|
|
99
|
+
#
|
|
100
|
+
# @api private
|
|
101
|
+
# @param value [Object] the parsed value
|
|
102
|
+
# @param array_class [Class] the class for parsing JSON arrays
|
|
103
|
+
# @param object_class [Class] the class for parsing JSON objects
|
|
104
|
+
# @return [Object] the value, built
|
|
105
|
+
def rebuild(value, array_class:, object_class:)
|
|
106
|
+
case value
|
|
107
|
+
when Hash then value.each_with_object(object_class.new) { |(key, item), object| object[key] = rebuild(item, array_class:, object_class:) }
|
|
108
|
+
when Array then value.each_with_object(array_class.new) { |item, array| array << rebuild(item, array_class:, object_class:) }
|
|
109
|
+
else value
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Run a callback of a line, tagging the error it raises
|
|
114
|
+
#
|
|
115
|
+
# The errors a socket raises are the errors a stream reconnects after, as is the InvalidResponse of a line that
|
|
116
|
+
# is not JSON, so a callback that raises one of them, or a JSON::ParserError of its own, is told apart from a
|
|
117
|
+
# connection that dropped.
|
|
118
|
+
#
|
|
119
|
+
# @api private
|
|
120
|
+
# @yield [] runs the callback
|
|
121
|
+
# @return [Object] what the callback returned
|
|
122
|
+
# @raise [CallbackError] if the callback raised an error
|
|
123
|
+
def tagging_callback_errors
|
|
124
|
+
yield
|
|
125
|
+
rescue => e
|
|
126
|
+
raise CallbackError, e
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Read the body in chunks and yield each line as it completes
|
|
130
|
+
#
|
|
131
|
+
# X ends each line it sends, so what is left when the stream ends without a line ending is a line the stream was
|
|
132
|
+
# cut off within, as when the server closes the connection mid-line. It is dropped rather than parsed, which would
|
|
133
|
+
# raise InvalidResponse, and back off as from a server error, for a stream that ended, and it is not passed to
|
|
134
|
+
# on_line, which would report it as an object the API bills: the stream ends as a dropped stream does.
|
|
135
|
+
#
|
|
136
|
+
# @api private
|
|
137
|
+
# @param response [StreamResponse] the response of the stream
|
|
138
|
+
# @param decode [Proc] the lambda that decodes a line
|
|
139
|
+
# @param on_keep_alive [#call] a callable called for each empty line
|
|
140
|
+
# @yield [Object] each decoded JSON document
|
|
141
|
+
# @return [void]
|
|
142
|
+
def read_lines(response:, decode:, on_keep_alive:, &)
|
|
143
|
+
buffer = +""
|
|
144
|
+
response.read_body do |chunk|
|
|
145
|
+
buffer << chunk
|
|
146
|
+
process_buffer(buffer:, decode:, on_keep_alive:, &)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Process complete lines from the buffer
|
|
151
|
+
# @api private
|
|
152
|
+
# @param buffer [String] the accumulated data buffer
|
|
153
|
+
# @param decode [Proc] decodes a line of JSON
|
|
154
|
+
# @param on_keep_alive [#call] a callable called for each empty line
|
|
155
|
+
# @yield [Object] each decoded JSON document
|
|
156
|
+
# @return [void]
|
|
157
|
+
def process_buffer(buffer:, decode:, on_keep_alive:, &)
|
|
158
|
+
while (line_end = buffer.index(LINE_DELIMITER))
|
|
159
|
+
line = buffer.slice!(0, line_end) # : String
|
|
160
|
+
buffer.delete_prefix!(LINE_DELIMITER)
|
|
161
|
+
line.empty? ? on_keep_alive.call : yield_json(line:, decode:, &)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Decode a line of JSON and yield the result
|
|
166
|
+
#
|
|
167
|
+
# The body of a stream arrives in binary chunks, and a chunk can end within a character, so each line
|
|
168
|
+
# is tagged UTF-8 once it is whole, before on_line, the decoder, or an error reads it. A line that is not valid
|
|
169
|
+
# UTF-8 keeps its bytes, as the body of a request does.
|
|
170
|
+
#
|
|
171
|
+
# @api private
|
|
172
|
+
# @param line [String] the JSON line to decode
|
|
173
|
+
# @param decode [Proc] decodes a line of JSON
|
|
174
|
+
# @yield [Object] the decoded JSON document
|
|
175
|
+
# @return [void]
|
|
176
|
+
def yield_json(line:, decode:)
|
|
177
|
+
yield decode.call(line.force_encoding(Encoding::UTF_8))
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
private_constant :StreamParser
|
|
181
|
+
end
|
|
182
|
+
end
|