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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 351c36af5826dec08a5ca7c014b7b34d18062bbe1de19b0a9593290c075e7f7d
4
+ data.tar.gz: c3473196d06dad56b10422b60859074fde027611fda1c99038e4461d2422487e
5
+ SHA512:
6
+ metadata.gz: f1570d57cec22227dc0ba01f18649c5874aca5422ea10953415379a17029c13d5e8a5b166c3eb3a9bdade21c899c028b0ee1427067690d7bce9d4a20e2b924f1
7
+ data.tar.gz: f05079b5e9b1783711f7938d4f5347376205ec7208b703d6d20617d32fe38c12716922459845cfa9c8f00cf88f5e297ce096581c7d77db42a04b69c42eb6f405
data/.yardopts ADDED
@@ -0,0 +1,8 @@
1
+ --markup markdown
2
+ --readme README.md
3
+ --hide-api private
4
+ --embed-mixins
5
+ lib/**/*.rb
6
+ -
7
+ CHANGELOG.md
8
+ LICENSE.txt
data/CHANGELOG.md ADDED
@@ -0,0 +1,99 @@
1
+ # Changelog
2
+
3
+ All notable changes to `x-streams` will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ `x-streams` is released in lockstep with the other gems of the [x-ruby](https://github.com/sferik/x-ruby) repository, at one version across `x-core`, `x-uploads`, `x-streams`, `x-resources`, and `x`. This file holds the changes to the streams; [the changelog of the repository](https://github.com/sferik/x-ruby/blob/main/CHANGELOG.md) holds the changes to every gem.
9
+
10
+ ## [1.0.0] - 2026-10-06
11
+
12
+ The first release of `x-streams`, which 1.0.0 split out of the `x` gem. The entries below are the changes since `x` 0.19, the last release before the split; see [UPGRADING.md](https://github.com/sferik/x-ruby/blob/main/UPGRADING.md) for the changes that code written for 0.19 needs. It requires Ruby 3.4 or later.
13
+
14
+ ### Added
15
+ * Split the `x` gem into five gems released in lockstep, at one version
16
+ * `x-core` (the HTTP client), `x-uploads` (uploads), `x-streams` (streams and their rules), `x-resources` (resources).
17
+ * `x` is a meta-gem that depends on all four and mixes their object, upload, and streaming methods into `X::Client`.
18
+ * `x-core` declares `X::Error`, the base class of every error the gems raise.
19
+ * Public classes sit directly under `X`; `X::Resources::Error`, `X::Uploads::Error`, and `X::Streams::Error` catch one gem's.
20
+ * `x` depends on exactly its own version of the other four; `x-uploads`, `x-streams`, and `x-resources` each depend on `x-core` with `>= 1.0.0, < 2`, so a later 1.x of `x-core` installs beside them, and never an earlier one.
21
+ * Stream with `X::StreamingClient`, which `X::Client#streaming` builds from a client
22
+ * It shares the client's credentials, base URL, parsing classes, and `on_response`, and keeps its own settings for life.
23
+ * `X::Client#streaming` takes `read_timeout:`, `max_reconnects:`, and `on_reconnect:`; for other settings, stream from `client.with(...)`.
24
+ * `stream` raises `ArgumentError` when given no block, before it opens the stream.
25
+ * Give a client `streaming` with `X::Streams::API`, which `x` includes into `X::Client`
26
+ * With `x-core` alone, call `X::Client.include(X::Streams::API)`, or build one with `X::StreamingClient.new(client)`.
27
+ * A streaming client opens each stream with `X::Client#get_stream` of `x-core`.
28
+ * Stop the streams of a streaming client from any thread, or the trap of a signal, with `X::StreamingClient#stop`
29
+ * Each stops the next time it waits on the API, at once for an idle stream or one waiting to reconnect.
30
+ * A stream a Fiber scheduler runs, as in an `Async` task, stops once its block next returns, at the next keep-alive, or within a second as it waits to reconnect.
31
+ * A stopped streaming client stays stopped: a stream asked of it later returns nil at once, without a request.
32
+ * Each stopped `stream` call returns nil, `stop` returns nil, and `stopped?` tells a stopped streaming client.
33
+ * A block, `on_response`, or `on_reconnect` running at that moment runs to its end first.
34
+ * `X::Client#streaming` builds a new streaming client each time, so keep the one you stop in a variable.
35
+ * Stop a stream from its block: `break` stops it and returns its value, and `throw` unwinds past it
36
+ * An error raised by the block, by `on_response`, or by the `object_class` stops the stream and reaches the caller.
37
+ * That includes `StopIteration`, and a `JSON::ParserError`, which is not taken for a line that is not JSON.
38
+ * Read and change the rules of the filtered stream with `rules`, `add_rules`, and `delete_rules` on `X::StreamingClient`
39
+ * They authenticate as the app, as a stream does; `rules` takes `params:` and reads every page.
40
+ * A rule to add is an `X::StreamRule`, a Hash of a `value` and `tag`, or a String; anything else raises `ArgumentError`.
41
+ * A rule is deleted by its `id` (from an `X::StreamRule`, a Hash, an Integer, or an `X::MatchingRule`, and nothing else that answers `id`) or by its value.
42
+ * So `delete_rules(post.matching_rules)` deletes the rules a post of `x-resources` matched.
43
+ * `dry_run: true` has the API check the rules and change none; no rules send no request.
44
+ * `add_rules` returns the rules added; `delete_rules` returns the number deleted.
45
+ * A rule the app already has, a `DuplicateRule`, raises nothing and is not among the rules returned; `rules` reads it, with its `id` and `tag`.
46
+ * Yield each problem the API reports of the rules it did not add or delete as an `X::Problem`, or raise `X::RulesRejected` without a block
47
+ * A block is yielded a `DuplicateRule` too; without one, only the rules X rejected raise, so `add_rules` can run at every boot.
48
+ * A rule X rejects, such as an invalid one, may keep it from adding any, and it reports that one alone; the rules returned say which were added.
49
+ * `X::RulesRejected` holds the `problems`, and what the method would have returned as `added` or `deleted_count`.
50
+ * Add `X::StreamRule`, a frozen value of a rule's `value`, `tag`, and `id`, an Integer or nil for a rule to add
51
+ * An `id` is an Integer that is not negative or a String of digits alone; anything else raises `ArgumentError`.
52
+ * It compares and hashes by all three, reads as a Hash with `to_h`, and matches a pattern of them.
53
+ * It writes itself with Marshal and YAML in a versioned format that every 1.x release reads back.
54
+ * A format it does not read raises `X::UnsupportedFormat`.
55
+ * Stream with app-only authentication from a client that signs with OAuth 1.0a, since the stream endpoints refuse it
56
+ * An OAuth 2.0 user client without app credentials streams, and reads and changes rules, as the user.
57
+ * X refuses that with 403, which raises `X::Forbidden`, not `X::UnsupportedOperation` before connecting.
58
+ * Raise `X::StreamError`, an `X::Streams::Error`, for a stream line that holds errors and no data
59
+ * It holds the line's `problems`, reads the stream's `http_method` and `uri`, and names them in its message.
60
+ * A line of `operational-disconnect`s alone reconnects, then raises once no reconnects are left; others raise at once.
61
+ * `X::Problem#disconnect?` tells an `operational-disconnect` apart, and `on_response` is passed the line first.
62
+ * Reconnect a stream that ends, drops, or delivers a line that is not JSON, backing off as X recommends
63
+ * It reconnects up to `max_reconnects` times in a row, unlimited by default.
64
+ * An object or keep-alive read from a connection open for a minute resets the count and the backoff; an earlier one resets neither.
65
+ * So a stream whose connections each deliver an object and drop backs off, and runs out of reconnects, rather than reconnect at once without end.
66
+ * A line that is not JSON raises `X::InvalidResponse`, and an ended stream `X::NetworkError`, once no reconnects are left.
67
+ * A rate limit backs off from a minute, doubling each attempt, or waits for a later reset.
68
+ * A wait past `max_rate_limit_wait`, or the project's usage cap, raises `X::TooManyRequests` at once.
69
+ * A server error, a 408, or a 409 backs off from 5 seconds, doubling up to 320 seconds, or waits longer for its `Retry-After`.
70
+ * A `Retry-After` past `max_rate_limit_wait` raises the error at once.
71
+ * What arrived of a line the stream ended within is dropped, neither parsed nor passed to `on_response`.
72
+ * An error `on_response` raises for a failed response stops the stream and reaches the caller, as one for an object does.
73
+ * A certificate that does not verify raises its `X::NetworkError` at once, since it would not verify on the next attempt.
74
+ * Report each reconnect of a stream to `on_reconnect:`, a callable passed the error and the seconds it waits
75
+ * It is passed an error every time, never nil, and is called with those two arguments and no others throughout 1.x.
76
+ * It can call `stop` to give up, as on a host that never resolves, and the stream returns nil.
77
+ * An error it raises stops the stream and reaches the caller.
78
+ * Read a stream with the `read_timeout` of the streaming client, 30 seconds by default
79
+ * Raise `ArgumentError` from `X::Client#streaming` and `X::StreamingClient.new` for an invalid setting
80
+ * `max_reconnects` must be an Integer of at least 0, or `Float::INFINITY`.
81
+ * `read_timeout` must be a finite number of seconds of at least 25, or nil, since X sends a keep-alive every 20 seconds.
82
+ * `stream` raises it too for an invalid `array_class`, `object_class`, or endpoint, before it opens the stream.
83
+ * Rescue the errors of `x-streams`, `X::StreamError` and `X::RulesRejected`, with `X::Streams::Error`, an `X::Error`
84
+ * Add `X::Streams.gem_version`, which returns `X::Streams::VERSION` as a `Gem::Version`
85
+ * Add `X::StreamingClient::DEFAULT_READ_TIMEOUT` and `X::StreamingClient::DEFAULT_MAX_RECONNECTS`
86
+ * Build `X::StreamError` and `X::RulesRejected` with public constructors, so code that rescues one can be tested
87
+ * Each takes an optional message and `problems:`, beside `http_method:` and `uri:`, or `added:` and `deleted_count:`.
88
+ * `raise X::StreamError, "dropped"` raises it as any other exception is raised.
89
+ * Raise `TypeError` from `Marshal.dump`, `YAML.dump`, `as_json`, and `to_json` of a streaming client
90
+ * They would write the credentials of its client in the clear.
91
+ * Name the internals of `x-streams` under `X::Streams`, as private constants marked `@api private`
92
+ * They are `StreamParser`, `ReconnectHandler`, `StreamRules`, `Validator`, `Stopper`, and `CallbackError`.
93
+ * Ship this changelog with `x-streams`, which the `changelog_uri` of its gemspec names
94
+ * Ship a `.yardopts` with `x-streams`, so its documentation on rubydoc.info leaves out the private API
95
+
96
+ ### Changed
97
+ * Move `stream` from `X::Client` to `X::StreamingClient`, so that the client carries no streaming settings
98
+
99
+ [1.0.0]: https://github.com/sferik/x-ruby/releases/tag/v1.0.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Erik Berlin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # x-streams
2
+
3
+ Streaming for the [`x` gem](https://github.com/sferik/x-ruby), built on the HTTP client in [`x-core`](https://github.com/sferik/x-ruby/tree/main/x-core).
4
+
5
+ * `X::StreamingClient` reads the sample and filtered streams of the X API, a post at a time, and reconnects a stream that drops, backing off as X recommends.
6
+ * It reads, adds, and deletes the rules of the filtered stream, each an `X::StreamRule`.
7
+
8
+ Its only dependency is `x-core`. Installing [`x`](https://rubygems.org/gems/x) installs this gem too, and `require "x"` gives every `X::Client` its `streaming` method.
9
+
10
+ ## Installation
11
+
12
+ `x-streams` requires Ruby 3.4 or later.
13
+
14
+ bundle add x-streams
15
+
16
+ ## Usage
17
+
18
+ ```ruby
19
+ require "x/core"
20
+ require "x/streams"
21
+
22
+ X::Client.include(X::Streams::API)
23
+
24
+ client = X::Client.new(bearer_token: "INSERT YOUR BEARER TOKEN HERE")
25
+
26
+ # A rule the app already has is not added again, and raises nothing, so the rules an app needs are added each time
27
+ # it boots; the rules returned are those added, and rules reads them all, each with its id and tag
28
+ client.streaming.add_rules([{value: "ruby -is:retweet", tag: "ruby"}, "crystal"])
29
+ client.streaming.rules # => [#<X::StreamRule id=1 value="ruby -is:retweet" tag="ruby">, ...]
30
+ client.streaming.stream("tweets/search/stream") { |post| puts post["data"]["text"] }
31
+
32
+ # A stream runs until its block, or stop, stops it: break to stop it and return a value, throw to unwind further out, or
33
+ # raise to stop it with an error the caller sees
34
+ first = client.streaming.stream("tweets/search/stream") { |post| break post }
35
+ ```
36
+
37
+ Another thread, or the trap of a signal, stops every stream a streaming client runs with `stop`, and each stream it stops returns nil. A stream that a Fiber scheduler runs, as in an `Async` task, is stopped once its block next returns, at the next keep-alive, within 20 seconds, or within a second as it waits to reconnect. A stopped streaming client stays stopped, as `stopped?` tells: a stream asked of it later returns nil without a request. Keep the streaming client you stop in a variable, since each call to `client.streaming` builds a new one.
38
+
39
+ `X::Client.include(X::Streams::API)` gives a client `streaming`, as `x` does, and a type checker needs the include declared too, as the `sig/x.rbs` of `x` declares it, with `class X::Client` and `include X::Streams::API` in a signature of your own; without the include, build a streaming client of a client with `X::StreamingClient.new(client)`, which takes the same `read_timeout:`, `max_reconnects:`, and `on_reconnect:`, a callable passed the error and the wait of each reconnect.
40
+
41
+ A streaming client opens each stream with `X::Client#get_stream` of `x-core`, the public method of a client that sends a GET whose body is read as it arrives, from an app-only copy of the client whose `read_timeout` is the stream's (a client that authenticates with OAuth 2.0 as a user and holds no credentials of the app streams as the user, which X refuses with `X::Forbidden`), so a stream carries the credentials, headers, and proxy of the client as any request does. It reads each line of the stream into the object it delivers, passes each to the client's `on_response` as an `X::Response`, and reconnects after a dropped connection, a server error, a rate limit, or an `operational-disconnect`, as the [`x` README](https://github.com/sferik/x-ruby#streaming) describes.
42
+
43
+ A stream reconnects at once the first time it drops, then waits longer before each reconnect in a row, up to `max_reconnects` of them. An object or keep-alive read from a connection that has been open for a minute starts the waits and the count over; one read from a younger connection starts neither over, so a stream whose connections each deliver something and drop backs off, and runs out of reconnects, rather than reconnect at once without end.
44
+
45
+ `X::StreamError`, which a line of a stream that holds errors and no data raises, and `X::RulesRejected`, which `add_rules` and `delete_rules` raise for the rules the API rejected when no block takes them (a rule the app already has is not one of them: `add_rules` raises nothing for it, yields its `DuplicateRule` to a block, and returns the rules it added alone, so read a rule the app already had, with its `id` and `tag`, with `rules`), descend from `X::Streams::Error`, which descends from `X::Error`, so `rescue X::Streams::Error` catches the errors x-streams raises of its own, and `rescue X::Error` every failure of a stream.
46
+
47
+ With `x-resources` loaded as well, a stream builds each post as an `X::Post` given `object_class: X::Post`, whose `matching_rules` are the rules of the filtered stream it matched.
48
+
49
+ ## Development
50
+
51
+ This gem has its own `Gemfile`, `Steepfile`, signatures, test suite, and mutation config. It uses the `x-core` in this repository and does not load `x-resources` or `x-uploads`:
52
+
53
+ bundle install
54
+ bundle exec rake test
55
+ bundle exec rake mutant
56
+ bundle exec rake steep
57
+ bundle exec rake yardstick
58
+
59
+ ## License
60
+
61
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "streaming_client"
4
+
5
+ module X
6
+ module Streams
7
+ # The streaming method mixed into a client, which builds a streaming client of the client
8
+ #
9
+ # The x gem includes it into X::Client. With x-core and x-streams alone, include it yourself:
10
+ # X::Client.include(X::Streams::API), or build a streaming client of a client with X::StreamingClient.new. It
11
+ # passes the object it is included into to the streaming client as its client, which is an X::Client, so it belongs
12
+ # in X::Client or a subclass.
13
+ #
14
+ # @api public
15
+ module API
16
+ # A client for the streaming endpoints, which reads and reconnects differently
17
+ #
18
+ # Each call builds a new streaming client, so the one a stream runs on is kept in a variable to stop it with
19
+ # {StreamingClient#stop}, and a streaming client that was stopped, which stays stopped, is replaced by another.
20
+ #
21
+ # @api public
22
+ # @param read_timeout [Integer, Float, nil] the timeout for reading from a stream in seconds, as
23
+ # {StreamingClient#initialize} takes it
24
+ # @param max_reconnects [Integer, Float] the maximum number of times in a row to reconnect a stream that drops, as
25
+ # {StreamingClient#initialize} takes it
26
+ # @param on_reconnect [#call, nil] a callable passed the error that dropped a stream and the seconds it waits
27
+ # before each reconnect, as {StreamingClient#initialize} takes it, or nil for none
28
+ # @return [StreamingClient] a streaming client that shares this client's credentials and settings
29
+ # @raise [ArgumentError] if the read timeout is neither a finite number of seconds of at least 25 nor nil, the
30
+ # maximum number of reconnects is neither a count nor Float::INFINITY, or on_reconnect neither responds to call
31
+ # nor is nil
32
+ # @example Stream filtered posts, giving up after five reconnects in a row
33
+ # client.streaming(max_reconnects: 5).stream("tweets/search/stream") { |post| puts post }
34
+ # @example Log each reconnect of a stream
35
+ # client.streaming(on_reconnect: ->(error, wait) { logger.warn("#{error.message}; reconnecting in #{wait}s") })
36
+ def streaming(read_timeout: StreamingClient::DEFAULT_READ_TIMEOUT, max_reconnects: StreamingClient::DEFAULT_MAX_RECONNECTS, on_reconnect: nil)
37
+ StreamingClient.new(_ = self, read_timeout:, max_reconnects:, on_reconnect:)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module X
4
+ module Streams
5
+ # Wraps an error a callback of a stream raised, so that it is not taken for an error of the stream
6
+ #
7
+ # A stream runs on_response, the from_response of its object_class, and its block inside the request that reads
8
+ # it, where an IOError or a SystemCallError a callback raised would be taken for a connection that dropped, and an
9
+ # X::ServiceUnavailable for the response of the stream, and the stream reconnected after it. So a callback's error
10
+ # is wrapped in this, which neither X::Client#get_stream nor the reconnects of a stream take for one of theirs, and
11
+ # raised as it was once the stream is left.
12
+ #
13
+ # Internal to x-streams: StreamParser tags the errors of a callback with it, as StreamingClient tags those of the
14
+ # on_response its stream client is passed a failed response with, and ReconnectHandler raises the error it holds
15
+ # in its place.
16
+ #
17
+ # @api private
18
+ class CallbackError < StandardError
19
+ # The error the callback raised
20
+ # @api private
21
+ # @return [StandardError] the error
22
+ attr_reader :error
23
+
24
+ # A callable that tags the error another raises, or nil for no callable
25
+ #
26
+ # X::Client#get_stream tags the error of the on_response it passes a failed response as its own, and raises it
27
+ # as it was, so a callable whose error is not to be taken for one of the stream raises this itself.
28
+ #
29
+ # @api private
30
+ # @param callable [#call, nil] the callable
31
+ # @return [Proc, nil] the callable that tags its errors, or nil if it is nil
32
+ # @example Tag the errors of the on_response of a client
33
+ # X::Streams::CallbackError.tagging(client.on_response)
34
+ def self.tagging(callable)
35
+ callable && lambda do |*arguments|
36
+ callable.call(*arguments)
37
+ rescue => e
38
+ raise new(e)
39
+ end
40
+ end
41
+
42
+ # Initialize a new CallbackError
43
+ # @api private
44
+ # @param error [StandardError] the error the callback raised
45
+ # @return [CallbackError] a new instance
46
+ def initialize(error)
47
+ super
48
+ @error = error
49
+ end
50
+ end
51
+ private_constant :CallbackError
52
+ end
53
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "x/core"
4
+
5
+ module X
6
+ module Streams
7
+ # Base error class for the failures of a stream, which every error x-streams raises of its own descends from
8
+ #
9
+ # It descends from X::Error, so that rescuing the errors of the X API catches the failure of a stream too. The
10
+ # errors that descend from it are named directly under X, as the errors of x-core are, so that this is the one name
11
+ # under X::Streams a rescue reaches for.
12
+ #
13
+ # It catches the errors x-streams raises of its own: a line of a stream that held errors and no data, and rules
14
+ # of the filtered stream the API left unchanged. It does not catch the X::Error of a request the API refused, or
15
+ # that got no response, such as the X::NetworkError of a stream that dropped with no reconnects left, nor the
16
+ # ArgumentError of a mistake in the arguments of a call. Rescue X::Error to catch every failure of a stream.
17
+ #
18
+ # @api public
19
+ class Error < X::Error
20
+ private
21
+
22
+ # The title and detail of each problem, separated by commas
23
+ #
24
+ # It is the message of an error built of problems without a message of its own.
25
+ #
26
+ # @api private
27
+ # @param problems [Array<Problem>] the problems
28
+ # @return [String, nil] the title and detail of each problem, or nil for none
29
+ def describe(problems)
30
+ problems.map { |problem| [problem.title, problem.detail || problem.message].compact.join(": ") }.join(", ") unless problems.empty?
31
+ end
32
+ end
33
+ end
34
+ end