x-core 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.
Files changed (83) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +8 -0
  3. data/CHANGELOG.md +353 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +54 -0
  6. data/lib/x/core/app_only_authenticator.rb +198 -0
  7. data/lib/x/core/authenticator.rb +74 -0
  8. data/lib/x/core/authenticator_request.rb +56 -0
  9. data/lib/x/core/bearer_token_authenticator.rb +46 -0
  10. data/lib/x/core/built_response.rb +100 -0
  11. data/lib/x/core/client.rb +728 -0
  12. data/lib/x/core/client_app_only.rb +115 -0
  13. data/lib/x/core/client_credentials.rb +193 -0
  14. data/lib/x/core/client_internals.rb +315 -0
  15. data/lib/x/core/client_memo.rb +58 -0
  16. data/lib/x/core/client_settings.rb +166 -0
  17. data/lib/x/core/client_token_refresh.rb +281 -0
  18. data/lib/x/core/connection.rb +328 -0
  19. data/lib/x/core/connection_pool.rb +123 -0
  20. data/lib/x/core/connection_proxy.rb +151 -0
  21. data/lib/x/core/connection_request.rb +90 -0
  22. data/lib/x/core/credential_holder.rb +78 -0
  23. data/lib/x/core/credential_validator.rb +286 -0
  24. data/lib/x/core/errors/authorization_denied.rb +55 -0
  25. data/lib/x/core/errors/authorization_error.rb +41 -0
  26. data/lib/x/core/errors/bad_gateway.rb +9 -0
  27. data/lib/x/core/errors/bad_request.rb +10 -0
  28. data/lib/x/core/errors/callback_error.rb +88 -0
  29. data/lib/x/core/errors/client_error.rb +17 -0
  30. data/lib/x/core/errors/conflict.rb +9 -0
  31. data/lib/x/core/errors/error.rb +66 -0
  32. data/lib/x/core/errors/forbidden.rb +10 -0
  33. data/lib/x/core/errors/gateway_timeout.rb +9 -0
  34. data/lib/x/core/errors/gone.rb +9 -0
  35. data/lib/x/core/errors/http_error.rb +333 -0
  36. data/lib/x/core/errors/internal_server_error.rb +9 -0
  37. data/lib/x/core/errors/invalid_response.rb +96 -0
  38. data/lib/x/core/errors/method_not_allowed.rb +10 -0
  39. data/lib/x/core/errors/network_error.rb +59 -0
  40. data/lib/x/core/errors/not_acceptable.rb +10 -0
  41. data/lib/x/core/errors/not_found.rb +15 -0
  42. data/lib/x/core/errors/payload_too_large.rb +10 -0
  43. data/lib/x/core/errors/payment_required.rb +10 -0
  44. data/lib/x/core/errors/request_timeout.rb +9 -0
  45. data/lib/x/core/errors/server_error.rb +15 -0
  46. data/lib/x/core/errors/service_unavailable.rb +9 -0
  47. data/lib/x/core/errors/token_report_failed.rb +59 -0
  48. data/lib/x/core/errors/too_many_redirects.rb +53 -0
  49. data/lib/x/core/errors/too_many_requests.rb +80 -0
  50. data/lib/x/core/errors/unauthorized.rb +15 -0
  51. data/lib/x/core/errors/unavailable_for_legal_reasons.rb +10 -0
  52. data/lib/x/core/errors/unprocessable_entity.rb +10 -0
  53. data/lib/x/core/errors/unsupported_format.rb +22 -0
  54. data/lib/x/core/errors/unsupported_media_type.rb +10 -0
  55. data/lib/x/core/errors/unsupported_operation.rb +18 -0
  56. data/lib/x/core/oauth1_authenticator.rb +142 -0
  57. data/lib/x/core/oauth2_authenticator.rb +296 -0
  58. data/lib/x/core/oauth2_authorization.rb +405 -0
  59. data/lib/x/core/oauth2_refresh.rb +272 -0
  60. data/lib/x/core/oauth2_tokens.rb +277 -0
  61. data/lib/x/core/origin.rb +95 -0
  62. data/lib/x/core/problem.rb +302 -0
  63. data/lib/x/core/proxy_setting.rb +22 -0
  64. data/lib/x/core/rate_limit.rb +137 -0
  65. data/lib/x/core/rate_limit_handler.rb +166 -0
  66. data/lib/x/core/redirect_handler.rb +208 -0
  67. data/lib/x/core/refresh_reporter.rb +92 -0
  68. data/lib/x/core/request_builder.rb +155 -0
  69. data/lib/x/core/request_context.rb +85 -0
  70. data/lib/x/core/request_encoding.rb +135 -0
  71. data/lib/x/core/response.rb +196 -0
  72. data/lib/x/core/response_headers.rb +31 -0
  73. data/lib/x/core/response_parser.rb +155 -0
  74. data/lib/x/core/retry_handler.rb +149 -0
  75. data/lib/x/core/setting_validator.rb +399 -0
  76. data/lib/x/core/stream_body.rb +77 -0
  77. data/lib/x/core/stream_response.rb +267 -0
  78. data/lib/x/core/token_endpoint.rb +238 -0
  79. data/lib/x/core/version.rb +25 -0
  80. data/lib/x/core.rb +6 -0
  81. data/sig/manifest.yaml +8 -0
  82. data/sig/x-core.rbs +396 -0
  83. metadata +162 -0
@@ -0,0 +1,399 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module X
6
+ module Core
7
+ # Checks the settings of the handlers of a client when the client is built
8
+ #
9
+ # A setting is compared with a count of attempts or a number of seconds only once a request has failed, so a
10
+ # setting that is not a number, such as a String read from an environment variable, would raise from that
11
+ # comparison, in place of the failure it was compared for. So each is checked when the handler is built, which
12
+ # is when the client that holds it is.
13
+ #
14
+ # A timeout is handed to Net::HTTP, which reads it only once a request waits, so a timeout that is not a number
15
+ # raises from inside Net::HTTP as the first request is sent, and Float::INFINITY raises there too, or ends the
16
+ # thread that Timeout keeps for every timeout of the process, since no deadline can be set that far off. So each
17
+ # is checked when the connection is built, which is when the client that holds it is.
18
+ #
19
+ # The base URL and the headers of a client are read only once a request is built, so a base URL that is no URL,
20
+ # or headers that are not a Hash of names to values, raised from the first request, rather than where the client
21
+ # was given them, so each is checked when the client is built.
22
+ #
23
+ # The classes a response is parsed into are read only once a response has arrived, so a class that cannot parse
24
+ # one, such as the String "Hash", raised once the API had answered, and billed, the request. So the default
25
+ # classes of a client are checked when the client is built, and the classes of a request before it is sent.
26
+ #
27
+ # The debug output of a client is written only once a request is sent, so one that takes no String, such as true,
28
+ # raised a NoMethodError from the first request, an Integer, whose << shifts it, a TypeError, and a String, as
29
+ # the name of a file, was appended every request, with its Authorization header, in place of the file. So it is
30
+ # checked when the connection is built.
31
+ #
32
+ # Internal to x-core: the handlers of redirects, rate limits, and retries, a connection, and a client check their
33
+ # settings with it.
34
+ #
35
+ # @api private
36
+ module SettingValidator
37
+ extend self
38
+
39
+ # The message of the error raised for a count that is not an Integer of at least 0
40
+ INVALID_COUNT = "%s must be an Integer of at least 0, not %s"
41
+ # The message of the error raised for seconds that are not a number of at least 0
42
+ INVALID_SECONDS = "%s must be a number of seconds of at least 0, not %s"
43
+ # The message of the error raised for seconds that are not a finite number of at least 0
44
+ INVALID_FINITE_SECONDS = "%s must be a finite number of seconds of at least 0, not %s"
45
+ # The message of the error raised for a timeout that is neither a finite number of seconds of at least 0 nor nil
46
+ INVALID_TIMEOUT = "%s must be a finite number of seconds of at least 0, or nil for no timeout, not %s"
47
+ # The message of the error raised for a base URL that is not an absolute HTTP or HTTPS URL
48
+ INVALID_BASE_URL = "base_url must be an absolute http or https URL with no query or fragment, such as \"https://api.x.com/2/\", not %s"
49
+ # The message of the error raised for a base URL that holds a user or a password, which it leaves out, since
50
+ # either may be a secret
51
+ BASE_URL_WITH_USERINFO = "base_url must hold no user or password, which no request sends"
52
+ # The scheme and authority of a URL whose authority holds a user or a password, before the at sign of the host
53
+ USERINFO = %r{\A[^:/?#]+://[^/?#]*@}
54
+ # The message of the error raised for headers that are not a Hash
55
+ INVALID_HEADERS = "headers must be a Hash of header names to values, not %s"
56
+ # The message of the error raised for a header whose name or value is not what a header takes
57
+ INVALID_HEADER = "headers must name each header with a String or a Symbol and give it a String, " \
58
+ "not %<name>s with %<value>s"
59
+ # The message of the error raised for a callable that does not respond to call
60
+ INVALID_CALLABLE = "%s must respond to call, as a Proc or a lambda does, or be nil, not %s"
61
+ # The message of the error raised for a debug output that takes no String with <<
62
+ INVALID_DEBUG_OUTPUT = "debug_output must take a String with <<, as an IO, a StringIO, or a Logger does, or be nil, not %s"
63
+ # The message of the error raised for a debug output that is a String, as the name of a file is
64
+ DEBUG_OUTPUT_STRING = "debug_output must be an IO, such as $stderr or File.open(\"debug.log\", \"a\"), or a StringIO " \
65
+ "to collect it, not a String, which names no file, and would be appended every request and response instead"
66
+ # The message of the error raised for a class to parse JSON arrays into that is not a Class
67
+ INVALID_ARRAY_CLASS = "%s must be a Class that JSON.parse builds each array into, such as Array, not %s"
68
+ # The message of the error raised for a class to parse JSON objects into that is not a Class, nor builds a result
69
+ INVALID_OBJECT_CLASS = "%s must be a Class that JSON.parse builds each object into, such as Hash, or respond to " \
70
+ "from_response, as the resource classes of x-resources do, not %s"
71
+ # The message of the error raised for keywords a request takes none of, as the fields of a body given without
72
+ # the braces of a Hash are read
73
+ UNKNOWN_KEYWORDS = "unknown keyword%s: %s; pass a body as a Hash in braces, as %s(%s, %s)"
74
+ # The message of the error raised for a body given as the keyword body, which most HTTP clients take it as
75
+ BODY_KEYWORD = "unknown keyword: :body; pass a body as the second argument, as %s(%s, %s)"
76
+ # The first letter of the name of a class that is named with an, rather than a
77
+ VOWEL = /\A[AEIOU]/
78
+ private_constant :INVALID_COUNT, :INVALID_SECONDS, :INVALID_FINITE_SECONDS, :INVALID_TIMEOUT,
79
+ :INVALID_BASE_URL, :INVALID_HEADERS, :INVALID_HEADER, :INVALID_CALLABLE, :INVALID_DEBUG_OUTPUT, :DEBUG_OUTPUT_STRING,
80
+ :INVALID_ARRAY_CLASS, :INVALID_OBJECT_CLASS, :UNKNOWN_KEYWORDS, :BODY_KEYWORD, :VOWEL
81
+
82
+ # Check that a count is an Integer of at least 0
83
+ #
84
+ # @api private
85
+ # @param name [Symbol] the name of the setting, which the error names
86
+ # @param value [Object] the value of the setting
87
+ # @return [Integer] the value
88
+ # @raise [ArgumentError] if the value is not an Integer of at least 0
89
+ # @example Check the retries of a client
90
+ # X::Core::SettingValidator.count!(:max_retries, 2) # => 2
91
+ def count!(name, value)
92
+ return value if count?(value)
93
+
94
+ raise ArgumentError, format(INVALID_COUNT, name, value.inspect)
95
+ end
96
+
97
+ # Check that seconds are a real number of at least 0
98
+ #
99
+ # @api private
100
+ # @param name [Symbol] the name of the setting, which the error names
101
+ # @param value [Object] the value of the setting
102
+ # @return [Integer, Float] the value
103
+ # @raise [ArgumentError] if the value is not a real number of at least 0
104
+ # @example Check the longest wait for a rate limit
105
+ # X::Core::SettingValidator.seconds!(:max_rate_limit_wait, 900) # => 900
106
+ def seconds!(name, value)
107
+ return value if seconds?(value)
108
+
109
+ raise ArgumentError, format(INVALID_SECONDS, name, value.inspect)
110
+ end
111
+
112
+ # Check that seconds are a finite real number of at least 0
113
+ #
114
+ # @api private
115
+ # @param name [Symbol] the name of the setting, which the error names
116
+ # @param value [Object] the value of the setting
117
+ # @return [Integer, Float] the value
118
+ # @raise [ArgumentError] if the value is not a finite real number of at least 0
119
+ # @example Check the time a connection is kept open
120
+ # X::Core::SettingValidator.finite_seconds!(:keep_alive_timeout, 30) # => 30
121
+ def finite_seconds!(name, value)
122
+ return value if finite_seconds?(value)
123
+
124
+ raise ArgumentError, format(INVALID_FINITE_SECONDS, name, value.inspect)
125
+ end
126
+
127
+ # Check that a timeout is finite seconds of at least 0, or nil for no timeout
128
+ #
129
+ # Net::HTTP waits for as long as it takes when a timeout is nil.
130
+ #
131
+ # @api private
132
+ # @param name [Symbol] the name of the setting, which the error names
133
+ # @param value [Object] the value of the setting
134
+ # @return [Integer, Float, nil] the value
135
+ # @raise [ArgumentError] if the value is neither a finite real number of at least 0 nor nil
136
+ # @example Check the timeout for reading a response
137
+ # X::Core::SettingValidator.timeout!(:read_timeout, 60) # => 60
138
+ def timeout!(name, value)
139
+ return value if value.nil? || finite_seconds?(value)
140
+
141
+ raise ArgumentError, format(INVALID_TIMEOUT, name, value.inspect)
142
+ end
143
+
144
+ # Check that a base URL is an absolute HTTP or HTTPS URL, with a host
145
+ #
146
+ # An endpoint is appended to the path of the base URL, so a query or a fragment, which would come before the
147
+ # endpoint rather than after it, is refused. So is a user or a password, which Net::HTTP sends with no request,
148
+ # and which the client would reveal in its inspect and in the URI of every error; the error that refuses one
149
+ # leaves the URL out, since the password is a secret.
150
+ #
151
+ # @api private
152
+ # @param value [Object] the base URL
153
+ # @return [String] the base URL
154
+ # @raise [ArgumentError] if the base URL is not a String that is an absolute http or https URL with a host, and
155
+ # no user, password, query, or fragment
156
+ # @example Check the base URL of the v1.1 API
157
+ # X::Core::SettingValidator.base_url!("https://api.x.com/1.1/") # => "https://api.x.com/1.1/"
158
+ def base_url!(value)
159
+ raise ArgumentError, BASE_URL_WITH_USERINFO if value.is_a?(String) && value.match?(USERINFO)
160
+ return -value if value.is_a?(String) && http_url?(value)
161
+
162
+ raise ArgumentError, format(INVALID_BASE_URL, value.inspect)
163
+ end
164
+
165
+ # A frozen copy of a String a caller gave, such as a credential, or nil
166
+ #
167
+ # A credential, token, header, or URL is copied as it is taken, so that the caller that gave it can change
168
+ # neither what is sent nor where.
169
+ #
170
+ # @api private
171
+ # @param value [String, nil] the String, or nil
172
+ # @return [String, nil] a frozen copy of the String, or nil
173
+ # @example Hold a credential
174
+ # X::Core::SettingValidator.frozen(+"token") # => "token"
175
+ def frozen(value) = value && -value
176
+
177
+ # Check that headers are a Hash of header names to String values
178
+ #
179
+ # The headers are returned with each named by a String. Each header must be named with a String or a Symbol. A Symbol names the header its underscores name with
180
+ # hyphens, as :content_type names Content-Type, so that it replaces the header of that name, and is dropped where
181
+ # that header is, rather than being sent beside it as a header no one sends. The error names the class of what is
182
+ # not a Hash, and the name of a header whose name or value is not what a header takes with the class of its
183
+ # value, rather than inspect either, since headers carry credentials, such as an Authorization header.
184
+ #
185
+ # @api private
186
+ # @param value [Object] the headers
187
+ # @return [Hash{String => String}] the headers, each named by a String
188
+ # @raise [ArgumentError] if the headers are not a Hash, or name a header with anything but a String or a
189
+ # Symbol, or give one anything but a String
190
+ # @example Check headers that name the application
191
+ # X::Core::SettingValidator.headers!("User-Agent" => "MyApp/1.0") # => {"User-Agent" => "MyApp/1.0"}
192
+ # @example Name a header with a Symbol
193
+ # X::Core::SettingValidator.headers!(content_type: "text/plain") # => {"content-type" => "text/plain"}
194
+ def headers!(value)
195
+ raise ArgumentError, format(INVALID_HEADERS, named(value.class)) unless value.is_a?(Hash)
196
+
197
+ invalid = value.find { |name, header| !header_name?(name) || !header.is_a?(String) }
198
+ invalid ? invalid_header!(*invalid) : value.to_h { |name, header| [header_name(name), -header] }
199
+ end
200
+
201
+ # Check that a callable responds to call, or is nil
202
+ #
203
+ # A callable is called only once what it is called for happens, such as a refresh, so one that does not respond
204
+ # to call would raise NoMethodError from inside a request, long after the client was given it. The error names
205
+ # its class rather than inspect it, since a callable can close over credentials.
206
+ #
207
+ # @api private
208
+ # @param name [Symbol] the name of the setting, which the error names
209
+ # @param value [Object] the callable, or nil for none
210
+ # @return [#call, nil] the value
211
+ # @raise [ArgumentError] if the value is neither nil nor responds to call
212
+ # @example Check the loader of the tokens a client shares
213
+ # X::Core::SettingValidator.callable!(:load_tokens, -> { store.load }) # => #<Proc (lambda)>
214
+ def callable!(name, value)
215
+ return value if value.nil? || value.respond_to?(:call)
216
+
217
+ raise ArgumentError, format(INVALID_CALLABLE, name, named(value.class))
218
+ end
219
+
220
+ # Check that a debug output takes a String with <<, or is nil
221
+ #
222
+ # Net::HTTP writes each request and response to it with <<, once a request is sent, so one that does not respond
223
+ # to << would raise NoMethodError from the first request. A String responds to <<, and would be appended every
224
+ # request, with its Authorization header, where the file it names was meant, so it is refused by name, with
225
+ # what to pass in its place: an IO, or a StringIO to collect what a String was meant to. A Proc and a Method
226
+ # respond to << as well, to compose another callable with, and an Integer to shift it, and each raises
227
+ # TypeError for a String, so each is refused as what does not respond to << is. The error names the class of
228
+ # the value rather than inspect it, as the error of a callable does.
229
+ #
230
+ # @api private
231
+ # @param value [Object] the debug output, or nil for none
232
+ # @return [#<<, nil] the value
233
+ # @raise [ArgumentError] if the value is a String, or is neither nil nor takes a String with <<
234
+ # @example Check the debug output of a client
235
+ # X::Core::SettingValidator.debug_output!($stderr) # => #<IO:<STDERR>>
236
+ def debug_output!(value)
237
+ raise ArgumentError, DEBUG_OUTPUT_STRING if String === value
238
+ return value if value.nil? || writer?(value)
239
+
240
+ raise ArgumentError, format(INVALID_DEBUG_OUTPUT, named(value.class))
241
+ end
242
+
243
+ # Check that the class to parse JSON arrays into is a Class
244
+ #
245
+ # JSON.parse builds each array of a body with the new of the class, and appends each element with <<.
246
+ #
247
+ # @api private
248
+ # @param name [Symbol] the name of the setting, which the error names
249
+ # @param value [Object] the class
250
+ # @return [Class] the value
251
+ # @raise [ArgumentError] if the value is not a Class
252
+ # @example Check the class a client parses arrays into
253
+ # X::Core::SettingValidator.array_class!(:default_array_class, Array) # => Array
254
+ def array_class!(name, value)
255
+ return value if value.instance_of?(Class)
256
+
257
+ raise ArgumentError, format(INVALID_ARRAY_CLASS, name, value.inspect)
258
+ end
259
+
260
+ # Check that the class to parse JSON objects into is a Class, or builds a result
261
+ #
262
+ # JSON.parse builds each object of a body with the new of a class, and sets each member with []=, unless it
263
+ # responds to from_response, which builds the result from the whole body instead; see {Client}.
264
+ #
265
+ # @api private
266
+ # @param name [Symbol] the name of the setting, which the error names
267
+ # @param value [Object] the class, or what builds the result of a request
268
+ # @return [Class, #from_response] the value
269
+ # @raise [ArgumentError] if the value is neither a Class nor responds to from_response
270
+ # @example Check the class a client parses objects into
271
+ # X::Core::SettingValidator.object_class!(:default_object_class, Hash) # => Hash
272
+ def object_class!(name, value)
273
+ return value if value.instance_of?(Class) || value.respond_to?(:from_response)
274
+
275
+ raise ArgumentError, format(INVALID_OBJECT_CLASS, name, value.inspect)
276
+ end
277
+
278
+ # Refuse the keywords a request takes none of, saying how to pass them as its body
279
+ #
280
+ # A body is a positional argument, so its fields given without the braces of a Hash, as in
281
+ # post("tweets", text: "Hello"), are read as keywords, which Ruby would refuse as unknown without saying that
282
+ # the body is passed in braces. A body given as the keyword body alone, which most HTTP clients take it as, is
283
+ # shown as the second argument instead, since body in braces would be sent as a field of the body. Beside other
284
+ # keywords, body is taken for a field among them.
285
+ #
286
+ # @api private
287
+ # @param http_method [Symbol] the method of the request, such as :post
288
+ # @param endpoint [String] the endpoint of the request
289
+ # @param keywords [Hash{Symbol, String => Object}] the keywords the request takes none of
290
+ # @return [void]
291
+ # @raise [ArgumentError] if there are any such keywords
292
+ # @example Refuse the fields of a body given without braces
293
+ # X::Core::SettingValidator.no_unknown_keywords!(:post, "tweets", {text: "Hello"})
294
+ # # raises ArgumentError: unknown keyword: :text; pass a body as a Hash in braces, as post("tweets", {text: "Hello"})
295
+ # @example Refuse a body given as a keyword
296
+ # X::Core::SettingValidator.no_unknown_keywords!(:post, "tweets", {body: {text: "Hello"}})
297
+ # # raises ArgumentError: unknown keyword: :body; pass a body as the second argument, as post("tweets", {text: "Hello"})
298
+ def no_unknown_keywords!(http_method, endpoint, keywords)
299
+ return if keywords.empty?
300
+ raise ArgumentError, format(BODY_KEYWORD, http_method, endpoint.inspect, keywords.fetch(:body).inspect) if keywords.keys.eql?([:body])
301
+
302
+ names = keywords.keys.map(&:inspect).join(", ")
303
+ raise ArgumentError, format(UNKNOWN_KEYWORDS, ("s" if keywords.size > 1), names, http_method, endpoint.inspect, keywords)
304
+ end
305
+
306
+ # Check the classes a request parses its response into, before the request is sent
307
+ #
308
+ # @api private
309
+ # @param array_class [Object] the class to parse JSON arrays into
310
+ # @param object_class [Object] the class to parse JSON objects into, or what builds the result of the request
311
+ # @return [void]
312
+ # @raise [ArgumentError] if the array class is not a Class, or the object class is neither a Class nor responds
313
+ # to from_response
314
+ # @example Check the classes of a request
315
+ # X::Core::SettingValidator.parsing_classes!(array_class: Array, object_class: X::User)
316
+ def parsing_classes!(array_class:, object_class:)
317
+ array_class!(:array_class, array_class)
318
+ object_class!(:object_class, object_class)
319
+ end
320
+
321
+ private
322
+
323
+ # Raise for a header whose name or value is not what a header takes
324
+ # @api private
325
+ # @param name [Object] the name of the header
326
+ # @param header [Object] the value of the header
327
+ # @return [void]
328
+ # @raise [ArgumentError] always
329
+ def invalid_header!(name, header)
330
+ raise ArgumentError, format(INVALID_HEADER, name: header_name?(name) ? name.inspect : named(name.class), value: named(header.class))
331
+ end
332
+
333
+ # A class named with its article, as an error names the class of what it refuses
334
+ # @api private
335
+ # @param klass [Class] the class
336
+ # @return [String] the name of the class after a, or an for a name that begins with a vowel, as an Integer
337
+ def named(klass) = "#{klass.to_s.match?(VOWEL) ? "an" : "a"} #{klass}"
338
+
339
+ # Check whether a value takes a String with <<, as an IO does
340
+ #
341
+ # The << of a Proc or a Method composes it with another callable, and the << of an Integer shifts it, rather
342
+ # than write to it, and each raises TypeError for a String.
343
+ #
344
+ # @api private
345
+ # @param value [Object] the value
346
+ # @return [Boolean] true if the value responds to <<, and is not a Proc, a Method, or an Integer
347
+ def writer?(value) = value.respond_to?(:<<) && !(Proc === value) && !(Method === value) && !(Integer === value)
348
+
349
+ # The String a header is named by
350
+ #
351
+ # A String names itself, and a Symbol the header its underscores name with hyphens.
352
+ #
353
+ # @api private
354
+ # @param name [String, Symbol] the name of the header
355
+ # @return [String] the name of the header
356
+ def header_name(name) = name.instance_of?(Symbol) ? name.name.tr("_", "-") : name
357
+
358
+ # Check whether a value names a header, as a String or a Symbol does
359
+ # @api private
360
+ # @param value [Object] the value
361
+ # @return [Boolean] true if the value is a String or a Symbol
362
+ def header_name?(value) = value.is_a?(String) || value.instance_of?(Symbol)
363
+
364
+ # Check whether a String is an absolute HTTP or HTTPS URL fit to be a base URL
365
+ # @api private
366
+ # @param value [String] the URL
367
+ # @return [Boolean] true if the URL is an absolute http or https URL with a host, and no query or fragment
368
+ def http_url?(value)
369
+ uri = URI(value)
370
+ uri.is_a?(URI::HTTP) && !uri.host.to_s.empty? && uri.query.nil? && uri.fragment.nil?
371
+ rescue URI::InvalidURIError
372
+ false
373
+ end
374
+
375
+ # Check whether a value is an Integer of at least 0
376
+ # @api private
377
+ # @param value [Object] the value
378
+ # @return [Boolean] true if the value is an Integer of at least 0
379
+ def count?(value) = value.instance_of?(Integer) && !value.negative?
380
+
381
+ # Check whether a value is a real number of at least 0
382
+ #
383
+ # Float::NAN is neither negative nor at least 0, and a wait compared with it is never longer, so it is compared
384
+ # with 0 rather than asked whether it is negative, which would let it pass as a limit that never applies.
385
+ #
386
+ # @api private
387
+ # @param value [Object] the value
388
+ # @return [Boolean] true if the value is a real number of at least 0
389
+ def seconds?(value) = value.is_a?(Numeric) && value.real? && value >= 0
390
+
391
+ # Check whether a value is a finite real number of at least 0
392
+ # @api private
393
+ # @param value [Object] the value
394
+ # @return [Boolean] true if the value is a finite real number of at least 0
395
+ def finite_seconds?(value) = seconds?(value) && value.finite?
396
+ end
397
+ private_constant :SettingValidator
398
+ end
399
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors/network_error"
4
+
5
+ module X
6
+ module Core
7
+ # Reads the body of a stream, raising the errors of the socket it is read from as a NetworkError
8
+ #
9
+ # The block of Client#get_stream reads the body of the response, and an error of a socket raised while it is read,
10
+ # such as the IOError of a body that dropped, or a read that timed out, is a NetworkError, which a stream
11
+ # reconnects after, while the block's own errors are raised as they were. The class of an error does not tell
12
+ # which it is, since code of the block's own, such as a write to a full disk, raises the errors a socket does. So
13
+ # the StreamResponse the block is passed reads its body through this, which raises a NetworkError for the errors
14
+ # read_body raises from the socket, and not for those of the block read_body passes each chunk to.
15
+ #
16
+ # Internal to x-core: StreamResponse#read_body reads the body of a stream with it, and the stream
17
+ # Client#get_stream opens raises an error untagged, as its own rather than the block's, only when socket_error?
18
+ # says read_body raised it.
19
+ #
20
+ # @api private
21
+ module StreamBody
22
+ # The NetworkErrors read_body raised for the socket, each held as its own key for as long as anything else holds
23
+ # it
24
+ SOCKET_ERRORS = ObjectSpace::WeakMap.new
25
+ private_constant :SOCKET_ERRORS
26
+
27
+ # Check whether an error is the NetworkError read_body raised for the socket
28
+ #
29
+ # @api private
30
+ # @param error [Exception] the error
31
+ # @return [Boolean] true if read_body raised it for an error of the socket
32
+ # @example Check an error a stream raised
33
+ # X::Core::StreamBody.socket_error?(error) # => false
34
+ def self.socket_error?(error) = SOCKET_ERRORS[error].equal?(error)
35
+
36
+ # Read the body, raising the errors of its socket as a NetworkError
37
+ #
38
+ # They are the network errors reading it raises that the sink of its chunks did not raise. The NetworkError is
39
+ # built as Connection builds one for a request that failed, naming the request of the stream, with the error of
40
+ # the socket as its cause, and noted, so that the stream raises it as it is.
41
+ #
42
+ # @api private
43
+ # @param uri [URI::Generic] the URI of the request of the stream, which the NetworkError names
44
+ # @param failed [Array<Exception>] the errors the sink of the chunks raised
45
+ # @yield reads the body
46
+ # @return [Object] what the block returns
47
+ # @raise [NetworkError] if the block raises an error of the socket
48
+ # @raise [StandardError] whatever else the block raises
49
+ def self.reading(uri, failed)
50
+ yield
51
+ rescue => e
52
+ raise unless Connection.network_error?(e) && !failed.include?(e)
53
+
54
+ error = NetworkError.new("Network error: #{e}", http_method: :get, uri:)
55
+ raise SOCKET_ERRORS[error] = error
56
+ end
57
+
58
+ # The block read_body passes each chunk to
59
+ #
60
+ # It notes the error the sink of the chunks raises, which is not the socket's.
61
+ #
62
+ # @api private
63
+ # @param sink [Proc] the block each chunk is passed to
64
+ # @param failed [Array<Exception>] the errors of the sink, which the error it raises is added to
65
+ # @return [Proc] the block
66
+ def self.passing(sink, failed)
67
+ lambda do |chunk|
68
+ sink.call(chunk)
69
+ rescue => e
70
+ failed << e
71
+ raise
72
+ end
73
+ end
74
+ end
75
+ private_constant :StreamBody
76
+ end
77
+ end