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.
@@ -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