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,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
|