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,544 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "x/core"
4
+ require_relative "callback_error"
5
+ require_relative "reconnect_handler"
6
+ require_relative "rules_rejected"
7
+ require_relative "stream_parser"
8
+ require_relative "stream_rule"
9
+ require_relative "stream_rules"
10
+ require_relative "stopper"
11
+ require_relative "validator"
12
+
13
+ module X
14
+ module Streams
15
+ # A client for the streaming endpoints, which hold a connection open rather than answer a request
16
+ #
17
+ # A stream reads until it is interrupted, so it reads with a short timeout and reconnects when it drops. The
18
+ # streaming endpoints require app-only authentication, so it streams with the bearer token of a client that has
19
+ # one, or fetches one with the API key and secret of a client that signs with OAuth 1.0a. It takes its credentials,
20
+ # base URL, parsing classes, and on_response hook from the client it was built from, and keeps the rest of its
21
+ # settings itself.
22
+ #
23
+ # Each stream opens a connection of its own and closes it when it ends, rather than keep one open for the request
24
+ # that follows, as a client does between requests. So a streaming client has neither the keep_alive_timeout of a
25
+ # client, which says how long a connection is kept open, nor its close, which closes the connections it kept: a
26
+ # streaming client keeps none between streams. A stream is stopped by breaking or raising from the block that
27
+ # reads it, or from another thread, or the trap of a signal, with {#stop}, which stops a stream that delivers
28
+ # nothing as well. A streaming client that was stopped stays stopped, and
29
+ # {X::Streams::API#streaming Client#streaming} builds a new one each time it is called, so a streaming client to
30
+ # be stopped is kept in a variable, rather than built again to stop.
31
+ #
32
+ # A stream is opened with X::Client#get_stream, of an app-only copy of the client whose read_timeout is the
33
+ # stream's, so it carries the credentials, headers, and proxy of the client as any request does, and none of its
34
+ # credentials to another origin than the base URL of the client.
35
+ #
36
+ # A streaming client keeps the settings it was built with for as long as it lives, as a client does, so a stream
37
+ # that runs for hours never reads a setting another thread is halfway through changing.
38
+ # {X::Streams::API#streaming Client#streaming} builds one whose read_timeout, max_reconnects, or on_reconnect
39
+ # differ, the settings a streaming client keeps itself, and it takes the rest of its settings from the client it is
40
+ # built from, which {#client} reads, so a stream that connects differently is opened from a copy of that client:
41
+ # client.with(open_timeout: 2).streaming.
42
+ #
43
+ # @api public
44
+ class ::X::StreamingClient
45
+ # Default timeout for reading from a stream in seconds, half again the 20-second interval of the keep-alive X sends
46
+ DEFAULT_READ_TIMEOUT = 30 # seconds
47
+ # Default maximum number of times in a row to reconnect a stream that drops without a connection that stays open
48
+ # for a minute
49
+ DEFAULT_MAX_RECONNECTS = ReconnectHandler::DEFAULT_MAX_RECONNECTS
50
+ # The message of the error raised for a stream without a block to deliver its objects to
51
+ NO_BLOCK_MESSAGE = "stream takes a block, which receives each object the stream delivers"
52
+ private_constant :NO_BLOCK_MESSAGE
53
+ # The message of the error raised for a stream the server ended, which a stream reconnects after as after a drop
54
+ ENDED_MESSAGE = "The stream ended"
55
+ private_constant :ENDED_MESSAGE
56
+ # The endpoint that reads and changes the rules the filtered stream matches posts against
57
+ RULES_ENDPOINT = "tweets/search/stream/rules"
58
+ private_constant :RULES_ENDPOINT
59
+ # The classes the rules endpoints parse into, whatever parsing classes the client defaults to
60
+ JSON_CLASSES = {array_class: Array, object_class: Hash}.freeze
61
+ private_constant :JSON_CLASSES
62
+ # The message of the error raised for Marshal or YAML, which names what refused it, as a client's does
63
+ REFUSAL_MESSAGE = "%s holds credentials, which %s would write in the clear wherever it is kept; keep the " \
64
+ "credentials in a secret store, and the X::OAuth2Tokens save_tokens is passed, and build it again from them"
65
+ private_constant :REFUSAL_MESSAGE
66
+
67
+ # The client the stream authenticates and parses with
68
+ # @api public
69
+ # @return [Client] the client
70
+ # @example Read the base URL of a stream
71
+ # streaming_client.client.base_url
72
+ attr_reader :client
73
+
74
+ # The callable passed the error that dropped a stream and its wait to reconnect
75
+ #
76
+ # A stream that drops reconnects up to max_reconnects times in a row, without end by default, so one that can
77
+ # never connect, as with a host that does not resolve, or a proxy that refuses it, would reconnect in silence
78
+ # for as long as it runs; only a certificate that does not verify raises at once. on_reconnect is passed the error that dropped
79
+ # the stream, such as an X::NetworkError or an X::ServiceUnavailable, and the seconds the stream waits before it
80
+ # reconnects, before each wait, so it can report the reconnects, or give up on them by calling {#stop}, after
81
+ # which the stream returns nil rather than reconnect. An error it raises stops the stream, and reaches the
82
+ # caller as it was raised, as an error of the block of the stream does. A stop does not cut it short, as it does
83
+ # not the block of the stream.
84
+ #
85
+ # It is called with these two arguments and no others, the error, which is never nil, since a stream reconnects
86
+ # only after one, and the wait, and every release of 1.x calls it so, so a lambda that takes exactly two, as
87
+ # ->(error, wait) does, serves each of them. What a later release of 1.x tells of a reconnect beside them, it
88
+ # passes to a hook of its own rather than to this one.
89
+ #
90
+ # @api public
91
+ # @return [#call, nil] the callable, or nil for none
92
+ # @example Read the callable passed each reconnect
93
+ # streaming_client.on_reconnect
94
+ attr_reader :on_reconnect
95
+
96
+ # Initialize a client for the streaming endpoints
97
+ #
98
+ # @api public
99
+ # @param client [Client] the client whose credentials, base URL, and settings the stream uses
100
+ # @param read_timeout [Integer, Float, nil] the timeout for reading from a stream in seconds, at least 25, five more
101
+ # than the 20 seconds between the keep-alives X sends a quiet stream, or nil for none, which leaves a stream X
102
+ # stopped sending to open until the operating system gives up on its connection
103
+ # @param max_reconnects [Integer, Float] the maximum number of times in a row to reconnect a stream that drops
104
+ # without a connection that stays open for a minute, or Float::INFINITY, the default, for no limit
105
+ # @param on_reconnect [#call, nil] a callable passed the error that dropped a stream and the seconds the stream
106
+ # waits before it reconnects, before each wait, or nil, the default, for none; see {#on_reconnect}
107
+ # @return [StreamingClient] a new instance
108
+ # @raise [ArgumentError] if the read timeout is neither a finite number of seconds of at least 25 nor nil, the
109
+ # maximum number of reconnects is neither a count nor Float::INFINITY, or on_reconnect neither responds to call
110
+ # nor is nil
111
+ # @example Create a streaming client
112
+ # streaming_client = X::StreamingClient.new(client, max_reconnects: 5)
113
+ # @example Give up on a stream that has failed to connect for minutes, rather than reconnect it without end
114
+ # streaming_client = X::StreamingClient.new(client, on_reconnect: lambda do |error, wait|
115
+ # logger.warn("#{error.class}: #{error.message}; reconnecting in #{wait} seconds")
116
+ # streaming_client.stop if error.is_a?(X::NetworkError) && wait >= 16
117
+ # end)
118
+ def initialize(client, read_timeout: DEFAULT_READ_TIMEOUT, max_reconnects: DEFAULT_MAX_RECONNECTS, on_reconnect: nil)
119
+ @client = client
120
+ @on_response = Stopper.guarding(client.on_response)
121
+ @stream_client = client.with(read_timeout: Validator.read_timeout!(:read_timeout, read_timeout), on_response: CallbackError.tagging(@on_response))
122
+ @on_reconnect = Validator.callable!(:on_reconnect, on_reconnect)
123
+ @reconnect_handler = ReconnectHandler.new(max_reconnects:, max_rate_limit_wait: client.max_rate_limit_wait, on_reconnect: Stopper.guarding(@on_reconnect))
124
+ @stream_parser = StreamParser.new
125
+ @stopper = Stopper.new
126
+ end
127
+
128
+ # The timeout for reading from a stream, in seconds
129
+ # @api public
130
+ # @return [Integer, Float, nil] the timeout, or nil for none
131
+ # @example Get the read timeout
132
+ # streaming_client.read_timeout # => 30
133
+ def read_timeout = @stream_client.read_timeout
134
+
135
+ # The maximum number of times in a row to reconnect a stream
136
+ #
137
+ # A stream is reconnected when it drops, and the count starts over each time it delivers an object, or reads the
138
+ # keep-alive X sends every 20 seconds, from a connection that has been open for a minute, so that a stream that
139
+ # is quiet but connected never runs out of reconnects. A connection that drops sooner starts no count over,
140
+ # whatever it delivered, so a stream whose connections each open and drop runs out of reconnects, and waits
141
+ # longer before each, rather than reconnect at once without end.
142
+ #
143
+ # @api public
144
+ # @return [Integer, Float] the maximum, or Float::INFINITY for no limit
145
+ # @example Get the maximum number of reconnects
146
+ # streaming_client.max_reconnects
147
+ def max_reconnects = @reconnect_handler.max_reconnects
148
+
149
+ # Summarize the streaming client for the console without revealing credentials
150
+ #
151
+ # @api public
152
+ # @return [String] the class name and the client it streams with
153
+ # @example Inspect a streaming client
154
+ # streaming_client.inspect # => #<X::StreamingClient client=#<X::Client ...>>
155
+ def inspect = "#<#{self.class} client=#{client.inspect}>"
156
+
157
+ # Stream data from the X API
158
+ #
159
+ # The stream endpoints take app-only authentication, so a client that authenticates as a user streams with the
160
+ # bearer token its app_only client holds. A client that authenticates with OAuth 2.0 as a user and holds neither
161
+ # the app's bearer token nor its API key and secret has no app_only client, so it opens the stream as the user,
162
+ # which X refuses with 403 Forbidden, raising Forbidden, as any request X refuses the credentials of a client for
163
+ # does. A bearer token X rejects with 401 Unauthorized, as it does one that was invalidated, is fetched again with
164
+ # the API key and secret the app_only client holds, as it is for a request, and the stream is opened once more
165
+ # with it. A stream that drops, or that X disconnects with an operational-disconnect, reconnects, backing off as X
166
+ # recommends, up to max_reconnects times in a row. The API bills the posts a stream delivers, each once a UTC
167
+ # day, so the client's on_response receives each object, as well as a failed response. An error on_response or the object_class raises
168
+ # stops the stream, and reaches the caller as it was raised, even an error of the X API, such as the
169
+ # X::ServiceUnavailable of a request on_response made, which a stream that dropped reconnects after.
170
+ #
171
+ # Reconnects are unlimited by default, and made in silence but for on_reconnect, so a stream that cannot connect,
172
+ # as for a host that does not resolve or a network that is down, reconnects every 16 seconds, once its backoff has
173
+ # grown that long, for as long as it runs. Set max_reconnects to give up after that many in a row, when the stream
174
+ # raises the error of the last, or pass an on_reconnect that calls {#stop}, after which the stream returns nil.
175
+ # Only a certificate that does not verify, which will not verify the next time either, raises at once, as an
176
+ # X::NetworkError whose cause is the OpenSSL::SSL::SSLError. A proxy URL in the environment that cannot be
177
+ # parsed, or is not an http or https URL with a host, as proxy.example.com:8080 is not, raises ArgumentError at
178
+ # once as well, from the first connection or from the reconnect that reads it.
179
+ #
180
+ # A stream runs until its block stops it: break out of the block to stop the stream and return a value, throw to
181
+ # unwind to a catch further out, or raise, which stops the stream even where a drop would have reconnected, and
182
+ # reaches the caller unchanged, a StopIteration included. Another thread stops it with {#stop}, which ends a
183
+ # stream that delivers nothing as well, when it returns nil, as a stream of a streaming client that was stopped
184
+ # does at once, without a request.
185
+ #
186
+ # @api public
187
+ # @param endpoint [String] the streaming API endpoint, relative to the base URL with or without a leading slash
188
+ # @param params [Hash, nil] query parameters appended to the endpoint
189
+ # @param headers [Hash] additional headers for the request, sent in place of the client's headers of the same
190
+ # name, which are themselves sent in place of the defaults of the gem
191
+ # @param array_class [Class] the class for parsing JSON arrays
192
+ # @param object_class [Class, #from_response] the class for parsing JSON objects, or one that responds to
193
+ # from_response and builds the result from each whole object the stream delivers; see X::Client
194
+ # @yield [Hash, Array] each parsed JSON object from the stream
195
+ # @return [Object, nil] what the block broke with, or nil for a stream {#stop} stopped, or was called before
196
+ # @raise [ArgumentError] if no block is given, or the endpoint is not a valid URL, or does not resolve to an http or
197
+ # https URL, before the stream is opened
198
+ # @raise [ArgumentError] if array_class is not a Class, or object_class is neither a Class nor responds to
199
+ # from_response, before the stream is opened
200
+ # @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for the request
201
+ # cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read
202
+ # for its scheme and leaves out the value, at once, without waiting to reconnect
203
+ # @raise [NetworkError] if the stream ends or drops, or cannot connect, with no reconnects left, or at once if the
204
+ # certificate of the connection does not verify
205
+ # @raise [HTTPError] if the response is not successful and the stream may not reconnect, or asks in its
206
+ # Retry-After header for a wait longer than the max_rate_limit_wait of the client
207
+ # @raise [StreamError] if a line holds errors and no data, which the stream reconnects after only when each is an
208
+ # operational-disconnect, and then raises once it has no reconnects left
209
+ # @example Stream filtered posts
210
+ # streaming_client.stream("tweets/search/stream") { |post| puts post }
211
+ # @example Stop the stream from its block
212
+ # first = streaming_client.stream("tweets/search/stream") { |post| break post }
213
+ def stream(endpoint, params: nil, headers: {}, array_class: client.default_array_class,
214
+ object_class: client.default_object_class, &block)
215
+ raise ArgumentError, NO_BLOCK_MESSAGE if block.nil?
216
+
217
+ Validator.parsing_classes!(array_class:, object_class:)
218
+ consumer = ->(object) { Stopper.guard { block.call(object) } }
219
+ @stopper.run do
220
+ @reconnect_handler.handle(consumer) do |deliver, alive|
221
+ app_only(@stream_client).get_stream(endpoint, params:, headers:) { |response| read(response, array_class:, object_class:, alive: Stopper.checking(alive), &deliver) }
222
+ end
223
+ end
224
+ end
225
+
226
+ # Stop every stream this streaming client runs, now and from then on
227
+ #
228
+ # A stream waits on the API for most of its life, for the next object, the keep-alive X sends every 20 seconds,
229
+ # or the next reconnect, so its block, which runs only when an object arrives, cannot stop a stream that delivers
230
+ # nothing. This stops each stream running in another thread, or in this one, the next time it waits on the API,
231
+ # at once for a stream waiting now, closing its connection, and the stream returns nil. A block, the
232
+ # on_response of the client, or a save_tokens it reports a refresh to, that is running when a stream is stopped
233
+ # runs to its end first, so that what it does with an object, on_response with a failed response, or the store
234
+ # of the tokens of a refresh, is never cut short. A stream that a Fiber scheduler runs, as in an Async task,
235
+ # waits on the API in the scheduler rather than in a read of its own, so it is stopped once its block next
236
+ # returns, at the next keep-alive, within 20 seconds, or within a second as it waits to reconnect; break out of
237
+ # its block, or stop its task, to end it at once.
238
+ #
239
+ # A streaming client that was stopped stays stopped: a stream it is asked to run later, as one a thread started
240
+ # just before stop may not yet have opened, returns nil at once, without a request.
241
+ # {X::Streams::API#streaming Client#streaming} builds a new streaming client to stream with again, and builds
242
+ # another each time it is called, so stop is called on the streaming client the stream runs on, kept in a
243
+ # variable, not on one a second call builds. It may be called from the trap of a signal, and waits for no lock,
244
+ # so it may return before the streams it stops have ended.
245
+ #
246
+ # @api public
247
+ # @return [nil]
248
+ # @example Stop a stream that runs in a thread of its own
249
+ # streaming_client = client.streaming
250
+ # reader = Thread.new { streaming_client.stream("tweets/search/stream") { |post| queue << post } }
251
+ # streaming_client.stop
252
+ # reader.join
253
+ # @example Stop a stream when the process is interrupted
254
+ # streaming_client = client.streaming
255
+ # Signal.trap("INT") { streaming_client.stop }
256
+ # streaming_client.stream("tweets/search/stream") { |post| puts post }
257
+ def stop = @stopper.stop
258
+
259
+ # Whether {#stop} was called, after which each stream returns nil at once
260
+ #
261
+ # @api public
262
+ # @return [Boolean] true once {#stop} was called
263
+ # @example Check whether a streaming client was stopped
264
+ # streaming_client.stopped? # => false
265
+ def stopped? = @stopper.stopped?
266
+
267
+ # The rules the filtered stream matches posts against
268
+ #
269
+ # The rules of an app are read, added, and deleted through a streaming client because they belong to the stream:
270
+ # they are what the filtered stream delivers, and they take the app-only authentication a stream takes. The API
271
+ # returns the rules a page at a time, and every page is read, so the rules are all of them. A page that names an
272
+ # empty next token, or the token of a page already read, is the last, as a page that names none is. The pages
273
+ # are read with while rather than Kernel#loop, which rescues StopIteration, so that a StopIteration the
274
+ # on_response of the client raises while a page is read reaches the caller, rather than end the reading in
275
+ # silence with the rules of the pages before it.
276
+ #
277
+ # @api public
278
+ # @param params [Hash, nil] query parameters appended to the endpoint of each page
279
+ # @return [Array<StreamRule>] the rules, frozen, empty if the app has none
280
+ # @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for the request
281
+ # cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read
282
+ # for its scheme and leaves out the value
283
+ # @raise [HTTPError] if the API refuses the request
284
+ # @example Print the rules of the app
285
+ # streaming_client.rules.each { |rule| puts "#{rule.tag}: #{rule.value}" }
286
+ # @example Read two rules by identifier
287
+ # streaming_client.rules(params: {ids: "1,2"})
288
+ def rules(params: nil)
289
+ rules = [] #: Array[StreamRule]
290
+ spent = [] #: Array[String]
291
+ while (token = read_rules(params, into: rules, spent:))
292
+ spent << token
293
+ params = params.to_h.merge(pagination_token: token)
294
+ end
295
+ rules.freeze
296
+ end
297
+
298
+ # Add rules for the filtered stream to match posts against
299
+ #
300
+ # A rule is a StreamRule, or a Hash of the value it matches and the tag it is labelled with, or a String, which is
301
+ # the value of a rule without a tag. A StreamRule is added by its value and tag, so the rules of one app, as
302
+ # {#rules} returns them, add themselves to another. No rules add none, and send no request. Anything else
303
+ # raises before a request, as it does for delete_rules.
304
+ #
305
+ # The API reports the rules it does not add as errors of a response that otherwise succeeds. A rule the
306
+ # app already has, which the API reports as a DuplicateRule, is not one it rejected: the rule exists, which is
307
+ # what was asked for, and it does not keep the API from adding the others, so adding the rules an app needs each
308
+ # time it boots raises nothing the second time. A rule the API rejects, such as one that is invalid, may keep it
309
+ # from adding any of them: given rules of which one was invalid, the API has added none of them and reported the
310
+ # invalid one alone. So the rules returned, and not the problems, say which rules were added. A rule the app
311
+ # already had is not among the rules returned, since the API reports neither the tag of the rule it kept nor,
312
+ # always, its identifier: {#rules} reads the rules of the app, each with the identifier and tag it holds. Each
313
+ # problem the API reported is yielded, as a finder of x-resources yields the problems of a lookup, a DuplicateRule
314
+ # among them, which tells a rule the app already had from one the API rejected; the problems may not name every
315
+ # rule that was not added. Without a block, a rule the API rejected, such as one that is invalid, raises
316
+ # RulesRejected, which holds the rules that were added, so that neither a rule the API rejected nor one a dry
317
+ # run found invalid is passed over in silence.
318
+ #
319
+ # @api public
320
+ # @param rules [Array<StreamRule, Hash, String>, StreamRule, Hash, String] the rules to add
321
+ # @param dry_run [Boolean] true to have the API check the rules and add none of them
322
+ # @yieldparam problem [Problem] each problem the API reported of the rules it did not add, such as a
323
+ # DuplicateRule; the problems may not name every rule it did not add
324
+ # @return [Array<StreamRule>] the rules that were added, or that a dry run would add, frozen, each holding the
325
+ # id the API gave it, empty if none were given, the app already had each of them, or the API added none of them
326
+ # @raise [ArgumentError] if something is neither a StreamRule, a Hash that holds a value, nor a String
327
+ # @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for the request
328
+ # cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read
329
+ # for its scheme and leaves out the value
330
+ # @raise [HTTPError] if the API refuses the request, which adds none of the rules
331
+ # @raise [RulesRejected] if the API rejected a rule, for any reason but that the app already has it, and no
332
+ # block was given for it
333
+ # @example Add a rule with a tag
334
+ # rule = streaming_client.add_rules(X::StreamRule.new(value: "ruby -is:retweet", tag: "ruby")).first
335
+ # rule.id # => 1165037377523306498
336
+ # @example Make sure the rules of an app exist, each time it boots
337
+ # streaming_client.add_rules([{value: "ruby -is:retweet", tag: "ruby"}, "crystal"]) # => [] once it has both
338
+ # streaming_client.rules.map(&:tag) # => ["ruby", nil]
339
+ # @example Check rules without adding them
340
+ # streaming_client.add_rules(["ruby", "crystal"], dry_run: true)
341
+ # @example Report the rules that were not added
342
+ # streaming_client.add_rules(%w[ruby crystal]) { |problem| warn "#{problem.value}: #{problem.title}" }
343
+ def add_rules(rules, dry_run: false, &)
344
+ rules = StreamRules.each_rule(rules)
345
+ body = change_rules({add: rules.map { |rule| StreamRules.rule_to_add(rule) }}, dry_run:) unless rules.empty?
346
+ added = StreamRules.rules_of(body)
347
+ reporting(Problem.all_from(body), {added:}, &)
348
+ added
349
+ end
350
+
351
+ # Delete rules of the filtered stream
352
+ #
353
+ # A rule is deleted by its identifier, or by the value it matches: a StreamRule the API returned, a Hash that holds
354
+ # an id, an Integer, or an X::MatchingRule is deleted by identifier, so what {#rules} returned deletes itself, as
355
+ # do the rules a post of x-resources matched, which its matching_rules holds, and a StreamRule or a Hash that holds
356
+ # a value and no identifier, or a String, is deleted by value, so what add_rules was given deletes what it added.
357
+ # Anything else raises before a request, even something else with an id, such as a post, whose identifier would
358
+ # delete whichever rule shared it. No rules delete none, and send no request, since the API refuses a deletion
359
+ # that names no rule.
360
+ #
361
+ # The API deletes the rules it can and reports the rest, such as a rule the app does not have, as errors of a
362
+ # response that otherwise succeeds. The number of rules that were deleted is returned, and each problem the API
363
+ # reported is yielded, as add_rules yields the rules it did not add, or, without a block, raises RulesRejected,
364
+ # which holds the number as deleted_count.
365
+ #
366
+ # @api public
367
+ # @param rules [Array<StreamRule, Hash, String, Integer, X::MatchingRule>, StreamRule, Hash, String, Integer,
368
+ # X::MatchingRule] the rules to delete, the values they match, or their identifiers
369
+ # @param dry_run [Boolean] true to have the API check the rules and delete none of them
370
+ # @yieldparam problem [Problem] each problem the API reported of the rules it did not delete
371
+ # @return [Integer] the number of rules deleted, or that a dry run would delete, 0 if none were given
372
+ # @raise [ArgumentError] if something is neither a rule nor the identifier of one, or holds an identifier that is
373
+ # neither an Integer that is not negative nor a String of digits alone
374
+ # @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for the request
375
+ # cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read
376
+ # for its scheme and leaves out the value
377
+ # @raise [HTTPError] if the API refuses the request
378
+ # @raise [RulesRejected] if the API reported a problem of a rule, and no block was given for it
379
+ # @example Delete every rule
380
+ # streaming_client.delete_rules(streaming_client.rules)
381
+ # @example Delete the rules that match two values
382
+ # streaming_client.delete_rules(["ruby", "crystal"])
383
+ # @example Delete a rule by the identifier the API gave it
384
+ # streaming_client.delete_rules(1165037377523306498)
385
+ # @example Report the rules that were not deleted
386
+ # streaming_client.delete_rules([1, 2]) { |problem| warn problem.detail }
387
+ # @example Delete the rules a post of x-resources matched
388
+ # streaming_client.delete_rules(post.matching_rules)
389
+ def delete_rules(rules, dry_run: false, &)
390
+ rules = StreamRules.each_rule(rules)
391
+ return 0 if rules.empty?
392
+
393
+ ids, values = rules.partition { |rule| StreamRules.identifier_of(rule) }
394
+ body = change_rules({delete: StreamRules.deletion(ids, values)}, dry_run:)
395
+ deleted_count = body.to_h.dig("meta", "summary", "deleted").to_i
396
+ reporting(Problem.all_from(body), {deleted_count:}, &)
397
+ deleted_count
398
+ end
399
+
400
+ # Refuse to be written with Marshal, which would write its credentials
401
+ #
402
+ # @api public
403
+ # @return [void]
404
+ # @raise [TypeError] always
405
+ # @example Keep the settings of a stream, rather than the streaming client
406
+ # settings = {read_timeout: streaming_client.read_timeout, max_reconnects: streaming_client.max_reconnects}
407
+ def marshal_dump = raise(TypeError, format(REFUSAL_MESSAGE, self.class, "Marshal"))
408
+
409
+ # Refuse to be written as YAML, which would write its credentials
410
+ #
411
+ # YAML reads no marshal_dump, and writes every instance variable of an object that does not say how it is
412
+ # written, the client and its credentials among them, so it is refused as Marshal is.
413
+ #
414
+ # @api public
415
+ # @param _coder [Psych::Coder] the coder YAML would write the streaming client with
416
+ # @return [void]
417
+ # @raise [TypeError] always
418
+ # @example Keep the settings of a stream, rather than the streaming client
419
+ # YAML.dump({"read_timeout" => streaming_client.read_timeout})
420
+ def encode_with(_coder) = raise(TypeError, format(REFUSAL_MESSAGE, self.class, "YAML"))
421
+
422
+ # Refuse to be read as JSON, which would write its credentials
423
+ #
424
+ # ActiveSupport's Object#as_json reads every instance variable of an object that does not say how it is read,
425
+ # the client and its credentials among them, so it is refused as YAML is.
426
+ #
427
+ # @api public
428
+ # @return [void]
429
+ # @raise [TypeError] always
430
+ # @example Render the settings of a stream, rather than the streaming client
431
+ # render json: {read_timeout: streaming_client.read_timeout}
432
+ def as_json(*) = raise(TypeError, format(REFUSAL_MESSAGE, self.class, "JSON"))
433
+
434
+ # Refuse to be written as JSON, which would write its credentials
435
+ #
436
+ # It raises as {#as_json} does, for the reason that says, so that JSON.generate refuses a streaming client within
437
+ # what it writes as well.
438
+ #
439
+ # @api public
440
+ # @param _state [JSON::State, nil] the state JSON would write the streaming client with
441
+ # @return [void]
442
+ # @raise [TypeError] always
443
+ # @example Log the settings of a stream, rather than the streaming client
444
+ # logger.info(JSON.generate(read_timeout: streaming_client.read_timeout))
445
+ def to_json(_state = nil) = raise(TypeError, format(REFUSAL_MESSAGE, self.class, "JSON"))
446
+
447
+ private
448
+
449
+ # Read a page of rules, as the app, adding them to those of the pages before it
450
+ # @api private
451
+ # @param params [Hash, nil] query parameters appended to the endpoint
452
+ # @param into [Array<StreamRule>] the rules of the pages before it, which the rules of the page are added to
453
+ # @param spent [Array<String>] the tokens that fetched the pages before it
454
+ # @return [String, nil] the token of the page after it, or nil if it is the last
455
+ def read_rules(params, into:, spent:)
456
+ body = app_client.get(RULES_ENDPOINT, params:, **JSON_CLASSES)
457
+ into.concat(StreamRules.rules_of(body))
458
+ StreamRules.next_token(body, spent)
459
+ end
460
+
461
+ # Send a change of the rules, as the app
462
+ # @api private
463
+ # @param body [Hash] the rules to add or delete
464
+ # @param dry_run [Boolean] true to have the API check the rules and change none of them
465
+ # @return [Hash, nil] the parsed response body
466
+ def change_rules(body, dry_run:) = app_client.post(RULES_ENDPOINT, body, params: {dry_run: (true if dry_run)}, **JSON_CLASSES)
467
+
468
+ # Yield each problem of a change of the rules, or raise without a block
469
+ #
470
+ # A rule the app already has is not one the API rejected, so it is yielded as any other problem is, and raises
471
+ # nothing.
472
+ #
473
+ # @api private
474
+ # @param problems [Array<Problem>] the problems the API reported
475
+ # @param changed [Hash{Symbol => Object}] what the change returns, as the error holds it: the rules added, or the
476
+ # number of rules deleted
477
+ # @yieldparam problem [Problem] each problem the API reported
478
+ # @return [void]
479
+ # @raise [RulesRejected] if the API rejected a rule and no block was given
480
+ def reporting(problems, changed, &block)
481
+ return problems.each(&block) if block
482
+
483
+ rejected = problems.reject { |problem| StreamRules.duplicate?(problem) }
484
+ raise RulesRejected.new(problems: rejected, **changed) if rejected.any?
485
+ end
486
+
487
+ # Read a stream once, and deliver each object it sends until it ends
488
+ #
489
+ # X holds a stream open until it drops it, so a stream the server ends raises a NetworkError, as one that drops
490
+ # does, which a stream reconnects after, and which reaches the caller once it has no reconnects left. A stream the
491
+ # server ends within a line raises it too, since the parser drops what it read of the line.
492
+ #
493
+ # @api private
494
+ # @param response [StreamResponse] the response of the stream, whose body is not yet read
495
+ # @param array_class [Class] the class for parsing JSON arrays
496
+ # @param object_class [Class, #from_response] the class for parsing JSON objects
497
+ # @param alive [#call] the callable to call for each keep-alive the stream reads
498
+ # @yield [Hash, Array] each parsed JSON object from the stream
499
+ # @return [void]
500
+ # @raise [NetworkError] once the stream ends
501
+ def read(response, array_class:, object_class:, alive:, &)
502
+ @stream_parser.process(response:, array_class:, object_class:, client:, on_line: ->(line) { report(response, line) }, on_keep_alive: alive, &)
503
+ raise NetworkError.new(ENDED_MESSAGE, http_method: :get, uri: response.uri)
504
+ end
505
+
506
+ # The client the rules are read and changed with, as the app where it can
507
+ # @api private
508
+ # @return [Client] the app-only client, or the client itself
509
+ def app_client = app_only(client)
510
+
511
+ # The app-only client of a client, or the client itself for one that has none
512
+ #
513
+ # A client that authenticates with OAuth 2.0 as a user and holds no credentials of the app has no app-only
514
+ # client, so it requests as the user, and X answers as it answers those credentials, refusing them with 403
515
+ # Forbidden, rather than be refused before the request.
516
+ #
517
+ # @api private
518
+ # @param client [Client] the client
519
+ # @return [Client] the app-only client, or the client itself
520
+ def app_only(client)
521
+ client.app_only
522
+ rescue UnsupportedOperation
523
+ client
524
+ end
525
+
526
+ # Pass one object of a stream to the client's on_response, guarded from stop
527
+ #
528
+ # The stream is opened with a copy of the client whose on_response calls the client's with Stopper.guard, which
529
+ # X::Client#get_stream passes a failed response, as this passes it each object. That copy tags the error the
530
+ # client's raises as a CallbackError, since X::Client#get_stream raises it as it was, where an
531
+ # X::ServiceUnavailable or an X::NetworkError would be taken for the stream's own and reconnected after, so this
532
+ # calls the client's on_response, guarded, rather than that copy's, whose error StreamParser tags as it tags the
533
+ # error of the object_class.
534
+ #
535
+ # @api private
536
+ # @param response [StreamResponse] the response of the stream
537
+ # @param line [String] the object the stream delivered
538
+ # @return [void]
539
+ def report(response, line)
540
+ @on_response&.call(Response.new(http_response: response.http_response, http_method: :get, uri: response.uri, body: line))
541
+ end
542
+ end
543
+ end
544
+ end