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