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,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "x/core"
|
|
4
|
+
require_relative "validator"
|
|
5
|
+
|
|
6
|
+
module X
|
|
7
|
+
module Streams
|
|
8
|
+
# A rule of the filtered stream: the value it matches posts against, the tag it is labelled with, and the
|
|
9
|
+
# identifier the API gave it
|
|
10
|
+
#
|
|
11
|
+
# {StreamingClient#rules} and {StreamingClient#add_rules} return the rules the API holds, and
|
|
12
|
+
# {StreamingClient#delete_rules} deletes one by its identifier, so a rule that was read deletes itself. A rule
|
|
13
|
+
# built to be added has no identifier until the API gives it one, and is deleted by the value it matches.
|
|
14
|
+
#
|
|
15
|
+
# It is frozen, compares equal to a rule of the same identifier, value, and tag, and matches a pattern of them, as
|
|
16
|
+
# in `rule in {value: /ruby/, tag: nil}`.
|
|
17
|
+
#
|
|
18
|
+
# @api public
|
|
19
|
+
class ::X::StreamRule
|
|
20
|
+
# The message of the error raised for a value or a tag that is not a String
|
|
21
|
+
NOT_A_STRING = "%s must be a String, not %s"
|
|
22
|
+
private_constant :NOT_A_STRING
|
|
23
|
+
|
|
24
|
+
# The number of the format of the state Marshal writes, which every release of 1.x writes
|
|
25
|
+
#
|
|
26
|
+
# A later release of 1.x adds to the state only what an earlier one ignores, parts after those it reads and keys of
|
|
27
|
+
# a Hash it does not read, so that the state one release of 1.x writes is read by every other, earlier or later.
|
|
28
|
+
MARSHAL_FORMAT = 1
|
|
29
|
+
private_constant :MARSHAL_FORMAT
|
|
30
|
+
|
|
31
|
+
# The identifier the API gave the rule
|
|
32
|
+
#
|
|
33
|
+
# The API sends it as a String, and it is read as an Integer, as the identifier of a resource of the object layer
|
|
34
|
+
# is, and as strictly: a String of digits alone, with no sign, underscore, or whitespace.
|
|
35
|
+
#
|
|
36
|
+
# @api public
|
|
37
|
+
# @return [Integer, nil] the identifier, or nil for a rule the API has not given one
|
|
38
|
+
# @example Get the identifier
|
|
39
|
+
# rule.id # => 1165037377523306498
|
|
40
|
+
attr_reader :id
|
|
41
|
+
|
|
42
|
+
# The value the rule matches posts against
|
|
43
|
+
# @api public
|
|
44
|
+
# @return [String] the value, in the syntax of the filtered stream
|
|
45
|
+
# @example Get the value
|
|
46
|
+
# rule.value # => "ruby -is:retweet"
|
|
47
|
+
attr_reader :value
|
|
48
|
+
|
|
49
|
+
# The tag the rule is labelled with, which each post it matches names
|
|
50
|
+
# @api public
|
|
51
|
+
# @return [String, nil] the tag, or nil for a rule without one
|
|
52
|
+
# @example Get the tag
|
|
53
|
+
# rule.tag # => "ruby"
|
|
54
|
+
attr_reader :tag
|
|
55
|
+
|
|
56
|
+
# Initialize a rule
|
|
57
|
+
#
|
|
58
|
+
# @api public
|
|
59
|
+
# @param value [String] the value the rule matches posts against
|
|
60
|
+
# @param tag [String, nil] the tag the rule is labelled with, or nil for none
|
|
61
|
+
# @param id [Integer, String, nil] the identifier the API gave the rule, as an Integer that is not negative or as
|
|
62
|
+
# the String of digits the API sends, or nil for a rule it has not given one
|
|
63
|
+
# @return [StreamRule] the frozen rule
|
|
64
|
+
# @raise [ArgumentError] if the value is not a String, the tag is neither a String nor nil, or the identifier is
|
|
65
|
+
# neither an Integer that is not negative, a String of digits alone, nor nil
|
|
66
|
+
# @example Build a rule to add
|
|
67
|
+
# X::StreamRule.new(value: "ruby -is:retweet", tag: "ruby")
|
|
68
|
+
def initialize(value:, tag: nil, id: nil)
|
|
69
|
+
@id = Validator.identifier!(id) unless id.nil?
|
|
70
|
+
@value = string!(:value, value)
|
|
71
|
+
@tag = string!(:tag, tag) unless tag.nil?
|
|
72
|
+
freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The rule as a Hash
|
|
76
|
+
#
|
|
77
|
+
# @api public
|
|
78
|
+
# @return [Hash{Symbol => Integer, String, nil}] the identifier, value, and tag
|
|
79
|
+
# @example Store a rule
|
|
80
|
+
# store.save(**rule.to_h)
|
|
81
|
+
def to_h = {id:, value:, tag:}
|
|
82
|
+
|
|
83
|
+
# The identifier, value, and tag of the rule, which a pattern matches against
|
|
84
|
+
#
|
|
85
|
+
# @api public
|
|
86
|
+
# @param _keys [Array<Symbol>, nil] the keys the pattern names
|
|
87
|
+
# @return [Hash{Symbol => Integer, String, nil}] the identifier, value, and tag
|
|
88
|
+
# @example Match the rules without a tag
|
|
89
|
+
# streaming_client.rules.select { |rule| rule in {tag: nil} }
|
|
90
|
+
def deconstruct_keys(_keys) = to_h
|
|
91
|
+
|
|
92
|
+
# Check whether another rule is the same rule
|
|
93
|
+
#
|
|
94
|
+
# @api public
|
|
95
|
+
# @param other [Object] the other rule
|
|
96
|
+
# @return [Boolean] true if the other rule is a StreamRule of the same identifier, value, and tag
|
|
97
|
+
# @example Check whether a rule was read before
|
|
98
|
+
# streaming_client.rules.include?(rule)
|
|
99
|
+
def ==(other) = other.instance_of?(self.class) && to_h.eql?(other.to_h)
|
|
100
|
+
alias_method :eql?, :==
|
|
101
|
+
|
|
102
|
+
# The hash of the rule, which equal rules share
|
|
103
|
+
#
|
|
104
|
+
# @api public
|
|
105
|
+
# @return [Integer] the hash
|
|
106
|
+
# @example Count the distinct rules
|
|
107
|
+
# rules.uniq.size
|
|
108
|
+
def hash = [self.class, to_h].hash
|
|
109
|
+
|
|
110
|
+
# Summarize the rule for the console
|
|
111
|
+
#
|
|
112
|
+
# @api public
|
|
113
|
+
# @return [String] the class name, identifier, value, and tag
|
|
114
|
+
# @example Inspect a rule
|
|
115
|
+
# rule.inspect # => #<X::StreamRule id=1165037377523306498 value="ruby -is:retweet" tag="ruby">
|
|
116
|
+
def inspect = "#<#{self.class} id=#{id.inspect} value=#{value.inspect} tag=#{tag.inspect}>"
|
|
117
|
+
|
|
118
|
+
# The state Marshal writes
|
|
119
|
+
#
|
|
120
|
+
# What is written is plain data, led by the number of its format, so that a rule written by one release of 1.x is
|
|
121
|
+
# read by a later one: its identifier, value, and tag, as to_h gives them.
|
|
122
|
+
#
|
|
123
|
+
# @api public
|
|
124
|
+
# @return [Array(Integer, Hash{Symbol => Integer, String, nil})] the number of the format, then the rule as a Hash
|
|
125
|
+
# @example Cache the rules of the filtered stream
|
|
126
|
+
# Rails.cache.write("rules", streaming_client.rules)
|
|
127
|
+
def marshal_dump = [MARSHAL_FORMAT, to_h]
|
|
128
|
+
|
|
129
|
+
# Restore a rule Marshal read, built as the constructor builds it, frozen
|
|
130
|
+
#
|
|
131
|
+
# @api public
|
|
132
|
+
# @param state [Array] the state Marshal wrote
|
|
133
|
+
# @return [void]
|
|
134
|
+
# @raise [UnsupportedFormat] if the state is of a format this release does not read
|
|
135
|
+
# @example Read cached rules
|
|
136
|
+
# Marshal.load(Marshal.dump(rule)).value
|
|
137
|
+
def marshal_load(state)
|
|
138
|
+
format, rule = state #: [Integer, {id: Integer?, value: String, tag: String?}]
|
|
139
|
+
raise UnsupportedFormat, "#{self.class} reads format #{MARSHAL_FORMAT} of Marshal, not #{format.inspect}" unless MARSHAL_FORMAT.eql?(format)
|
|
140
|
+
|
|
141
|
+
initialize(**rule.slice(:id, :value, :tag)) # steep:ignore InsufficientKeywordArguments
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Write the state Marshal writes as YAML
|
|
145
|
+
#
|
|
146
|
+
# YAML would write the instance variables of the rule, and read them back into a rule that is not frozen, so
|
|
147
|
+
# it says how it is written: the number of its format, then each of its parts, under the name to_h gives it.
|
|
148
|
+
#
|
|
149
|
+
# @api public
|
|
150
|
+
# @param coder [Psych::Coder] the coder YAML writes the rule with
|
|
151
|
+
# @return [void]
|
|
152
|
+
# @example Write a rule as YAML
|
|
153
|
+
# YAML.dump(rule)
|
|
154
|
+
def encode_with(coder)
|
|
155
|
+
coder["format"] = MARSHAL_FORMAT
|
|
156
|
+
to_h.each { |key, value| coder[key.to_s] = value }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Restore a rule YAML read, frozen, as Marshal restores one
|
|
160
|
+
#
|
|
161
|
+
# @api public
|
|
162
|
+
# @param coder [Psych::Coder] the coder YAML read the rule with
|
|
163
|
+
# @return [void]
|
|
164
|
+
# @raise [UnsupportedFormat] if the state is of a format this release does not read
|
|
165
|
+
# @example Read a rule written as YAML
|
|
166
|
+
# YAML.unsafe_load(YAML.dump(rule)).value
|
|
167
|
+
def init_with(coder) = marshal_load([coder["format"], coder.map.transform_keys(&:to_sym)])
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
# A frozen copy of a String a rule holds
|
|
172
|
+
# @api private
|
|
173
|
+
# @param name [Symbol] the name of the attribute, which the error names
|
|
174
|
+
# @param string [Object] the value of the attribute
|
|
175
|
+
# @return [String] the frozen copy
|
|
176
|
+
# @raise [ArgumentError] if the value is not a String
|
|
177
|
+
def string!(name, string)
|
|
178
|
+
(String.try_convert(string) || raise(ArgumentError, format(NOT_A_STRING, name, string.inspect))).dup.freeze
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "stream_rule"
|
|
4
|
+
require_relative "validator"
|
|
5
|
+
|
|
6
|
+
module X
|
|
7
|
+
module Streams
|
|
8
|
+
# The rules of the filtered stream, read from what a caller gives and what the API returns
|
|
9
|
+
#
|
|
10
|
+
# A rule is given as a StreamRule, a Hash, a String, or, to delete it, an Integer or the X::MatchingRule of a post
|
|
11
|
+
# of x-resources, and each of the rules methods of a streaming client reads it here into what the API takes.
|
|
12
|
+
#
|
|
13
|
+
# Internal to x-streams: StreamingClient reads the rules it adds and deletes with it.
|
|
14
|
+
#
|
|
15
|
+
# @api private
|
|
16
|
+
module StreamRules
|
|
17
|
+
extend self
|
|
18
|
+
|
|
19
|
+
# The message of the error raised for something that is neither a rule nor the identifier of one
|
|
20
|
+
NOT_A_RULE = "a rule is a StreamRule, a Hash holding an id or a value, an X::MatchingRule, the value it matches, " \
|
|
21
|
+
"or its identifier, not %s"
|
|
22
|
+
# The message of the error raised for something that is neither a rule to add nor the value one matches
|
|
23
|
+
NOT_A_RULE_TO_ADD = "a rule to add is a StreamRule, a Hash holding a value, or the value it matches, not %s"
|
|
24
|
+
# What the type of the problem of a rule the app already has ends in
|
|
25
|
+
DUPLICATE_RULES = "/duplicate-rules"
|
|
26
|
+
private_constant :NOT_A_RULE, :NOT_A_RULE_TO_ADD, :DUPLICATE_RULES
|
|
27
|
+
|
|
28
|
+
# The rules to delete, named by identifier and by the value they match
|
|
29
|
+
#
|
|
30
|
+
# A list the API is given none of would delete every rule, so neither is sent unless it holds something. The API
|
|
31
|
+
# takes an identifier as a String, as it sends one, so an Integer is sent as one, and each is read as strictly
|
|
32
|
+
# as a StreamRule reads one, so that " 1_0 " is not sent for 10, nor -1 for a rule.
|
|
33
|
+
#
|
|
34
|
+
# @api private
|
|
35
|
+
# @param ids [Array] the rules that hold an identifier
|
|
36
|
+
# @param values [Array] the rules that hold a value and no identifier
|
|
37
|
+
# @return [Hash{Symbol => Array}] the identifiers and values of the rules to delete
|
|
38
|
+
# @raise [ArgumentError] if an identifier is neither an Integer that is not negative nor a String of digits
|
|
39
|
+
def deletion(ids, values)
|
|
40
|
+
{ids: ids.map { |rule| Validator.identifier!(identifier_of(rule)).to_s }, values: values.map { |rule| value_of(rule) }}.reject { |_, list| list.empty? }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The rules given, which may be one rule rather than a list of them
|
|
44
|
+
#
|
|
45
|
+
# Only an Array is read as a list of rules, and anything else as one rule, since Array() would read a Hash as the
|
|
46
|
+
# list of its pairs, and a Struct as the list of its members, so that a Struct of a value and a tag would add its
|
|
47
|
+
# tag as a rule, and one of an identifier and a text delete the rule of that identifier. nil is no rules. A
|
|
48
|
+
# StreamRule is read as the Hash of what it holds, which the rest read as they read any other.
|
|
49
|
+
#
|
|
50
|
+
# @api private
|
|
51
|
+
# @param rules [Array, StreamRule, Hash, String, Integer, nil] the rules, or one rule
|
|
52
|
+
# @return [Array] the rules
|
|
53
|
+
def each_rule(rules)
|
|
54
|
+
listed = rules.nil? ? [] : Array.try_convert(rules) || [rules] #: Array[untyped]
|
|
55
|
+
listed.map { |rule| rule.is_a?(StreamRule) ? rule.to_h.compact : rule }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The rules of a response, which holds none when it changed or matched none
|
|
59
|
+
# @api private
|
|
60
|
+
# @param body [Hash, nil] the parsed response body
|
|
61
|
+
# @return [Array<StreamRule>] the rules, frozen
|
|
62
|
+
def rules_of(body)
|
|
63
|
+
rules = Array(body.to_h["data"]) #: Array[Hash[String, untyped]]
|
|
64
|
+
rules.map { |rule| StreamRule.new(id: rule["id"], value: rule["value"], tag: rule["tag"]) }.freeze
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Check whether a problem is of a rule the app already has
|
|
68
|
+
#
|
|
69
|
+
# The API reports a rule it was asked to add that matches the value of a rule the app has as a DuplicateRule,
|
|
70
|
+
# whose type ends in duplicate-rules. It is not a rule the API rejected, so add_rules raises nothing for it.
|
|
71
|
+
#
|
|
72
|
+
# @api private
|
|
73
|
+
# @param problem [Problem] the problem the API reported
|
|
74
|
+
# @return [Boolean] true if the problem is a DuplicateRule
|
|
75
|
+
def duplicate?(problem) = problem.type.to_s.end_with?(DUPLICATE_RULES)
|
|
76
|
+
|
|
77
|
+
# The token of the page of rules after a response, or nil for the last page
|
|
78
|
+
#
|
|
79
|
+
# An empty token names no page, and a token that fetched a page already would have the pages requested again for
|
|
80
|
+
# good, and the API bills each request, so the page that names either is the last, as a page that names none is.
|
|
81
|
+
#
|
|
82
|
+
# @api private
|
|
83
|
+
# @param body [Hash, nil] the parsed response body
|
|
84
|
+
# @param spent [Array<String>] the tokens that fetched the pages before it
|
|
85
|
+
# @return [String, nil] the token, or nil if the page is the last
|
|
86
|
+
def next_token(body, spent)
|
|
87
|
+
token = body.to_h.dig("meta", "next_token")
|
|
88
|
+
token unless ["", *spent].include?(token)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# A rule to add, from the rule itself or the value it matches
|
|
92
|
+
#
|
|
93
|
+
# The API gives each rule it adds an identifier of its own, so one that a rule holds, as a rule that was read
|
|
94
|
+
# does, is not sent.
|
|
95
|
+
#
|
|
96
|
+
# @api private
|
|
97
|
+
# @param rule [Hash, String] the rule, or the value it matches
|
|
98
|
+
# @return [Hash] the rule
|
|
99
|
+
# @raise [ArgumentError] if the rule is neither a String nor a Hash that holds a value
|
|
100
|
+
def rule_to_add(rule)
|
|
101
|
+
value = String.try_convert(rule)
|
|
102
|
+
return {value:} if value
|
|
103
|
+
|
|
104
|
+
hash = Hash.try_convert(rule)
|
|
105
|
+
return hash.except("id", :id) if hash && (hash["value"] || hash[:value])
|
|
106
|
+
|
|
107
|
+
raise ArgumentError, format(NOT_A_RULE_TO_ADD, rule.inspect)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The identifier of a rule, if it is one or holds one
|
|
111
|
+
#
|
|
112
|
+
# A String is the value a rule matches, as add_rules reads it, so an identifier is an Integer, held by a Hash,
|
|
113
|
+
# or read from the id of an X::MatchingRule, which each post of x-resources names in matching_rules, and which holds
|
|
114
|
+
# no value. Anything else with an id is not read for one, since a post, a user, or any other resource has an id
|
|
115
|
+
# too, which would delete whichever rule shared it.
|
|
116
|
+
#
|
|
117
|
+
# @api private
|
|
118
|
+
# @param rule [Hash, String, Integer, X::MatchingRule] the rule, the value it matches, or its identifier
|
|
119
|
+
# @return [Object, nil] the identifier, or nil for a rule that holds none
|
|
120
|
+
def identifier_of(rule)
|
|
121
|
+
hash = Hash.try_convert(rule)
|
|
122
|
+
return hash["id"] || hash[:id] if hash
|
|
123
|
+
return rule if rule.instance_of?(Integer)
|
|
124
|
+
|
|
125
|
+
rule.id if matching_rule?(rule)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Check whether a rule is an X::MatchingRule
|
|
129
|
+
#
|
|
130
|
+
# x-streams does not depend on x-resources, which defines X::MatchingRule, so no rule is one until x-resources is
|
|
131
|
+
# loaded.
|
|
132
|
+
#
|
|
133
|
+
# @api private
|
|
134
|
+
# @param rule [Object] the rule
|
|
135
|
+
# @return [Boolean, nil] true if x-resources is loaded and the rule is an X::MatchingRule
|
|
136
|
+
def matching_rule?(rule) = defined?(X::MatchingRule) && rule.is_a?(X::MatchingRule) # steep:ignore UnknownConstant
|
|
137
|
+
|
|
138
|
+
# The value a rule matches, which deletes a rule holding no identifier
|
|
139
|
+
# @api private
|
|
140
|
+
# @param rule [Hash, String] the rule, or the value it matches
|
|
141
|
+
# @return [String] the value
|
|
142
|
+
# @raise [ArgumentError] if the rule is neither a String nor a Hash that holds an identifier or a value
|
|
143
|
+
def value_of(rule)
|
|
144
|
+
value = String.try_convert(rule)
|
|
145
|
+
return value if value
|
|
146
|
+
|
|
147
|
+
hash = Hash.try_convert(rule) || {} #: Hash[untyped, untyped]
|
|
148
|
+
hash["value"] || hash[:value] || raise(ArgumentError, format(NOT_A_RULE, rule.inspect))
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
private_constant :StreamRules
|
|
152
|
+
end
|
|
153
|
+
end
|