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.
- checksums.yaml +7 -0
- data/.yardopts +8 -0
- data/CHANGELOG.md +353 -0
- data/LICENSE.txt +21 -0
- data/README.md +54 -0
- data/lib/x/core/app_only_authenticator.rb +198 -0
- data/lib/x/core/authenticator.rb +74 -0
- data/lib/x/core/authenticator_request.rb +56 -0
- data/lib/x/core/bearer_token_authenticator.rb +46 -0
- data/lib/x/core/built_response.rb +100 -0
- data/lib/x/core/client.rb +728 -0
- data/lib/x/core/client_app_only.rb +115 -0
- data/lib/x/core/client_credentials.rb +193 -0
- data/lib/x/core/client_internals.rb +315 -0
- data/lib/x/core/client_memo.rb +58 -0
- data/lib/x/core/client_settings.rb +166 -0
- data/lib/x/core/client_token_refresh.rb +281 -0
- data/lib/x/core/connection.rb +328 -0
- data/lib/x/core/connection_pool.rb +123 -0
- data/lib/x/core/connection_proxy.rb +151 -0
- data/lib/x/core/connection_request.rb +90 -0
- data/lib/x/core/credential_holder.rb +78 -0
- data/lib/x/core/credential_validator.rb +286 -0
- data/lib/x/core/errors/authorization_denied.rb +55 -0
- data/lib/x/core/errors/authorization_error.rb +41 -0
- data/lib/x/core/errors/bad_gateway.rb +9 -0
- data/lib/x/core/errors/bad_request.rb +10 -0
- data/lib/x/core/errors/callback_error.rb +88 -0
- data/lib/x/core/errors/client_error.rb +17 -0
- data/lib/x/core/errors/conflict.rb +9 -0
- data/lib/x/core/errors/error.rb +66 -0
- data/lib/x/core/errors/forbidden.rb +10 -0
- data/lib/x/core/errors/gateway_timeout.rb +9 -0
- data/lib/x/core/errors/gone.rb +9 -0
- data/lib/x/core/errors/http_error.rb +333 -0
- data/lib/x/core/errors/internal_server_error.rb +9 -0
- data/lib/x/core/errors/invalid_response.rb +96 -0
- data/lib/x/core/errors/method_not_allowed.rb +10 -0
- data/lib/x/core/errors/network_error.rb +59 -0
- data/lib/x/core/errors/not_acceptable.rb +10 -0
- data/lib/x/core/errors/not_found.rb +15 -0
- data/lib/x/core/errors/payload_too_large.rb +10 -0
- data/lib/x/core/errors/payment_required.rb +10 -0
- data/lib/x/core/errors/request_timeout.rb +9 -0
- data/lib/x/core/errors/server_error.rb +15 -0
- data/lib/x/core/errors/service_unavailable.rb +9 -0
- data/lib/x/core/errors/token_report_failed.rb +59 -0
- data/lib/x/core/errors/too_many_redirects.rb +53 -0
- data/lib/x/core/errors/too_many_requests.rb +80 -0
- data/lib/x/core/errors/unauthorized.rb +15 -0
- data/lib/x/core/errors/unavailable_for_legal_reasons.rb +10 -0
- data/lib/x/core/errors/unprocessable_entity.rb +10 -0
- data/lib/x/core/errors/unsupported_format.rb +22 -0
- data/lib/x/core/errors/unsupported_media_type.rb +10 -0
- data/lib/x/core/errors/unsupported_operation.rb +18 -0
- data/lib/x/core/oauth1_authenticator.rb +142 -0
- data/lib/x/core/oauth2_authenticator.rb +296 -0
- data/lib/x/core/oauth2_authorization.rb +405 -0
- data/lib/x/core/oauth2_refresh.rb +272 -0
- data/lib/x/core/oauth2_tokens.rb +277 -0
- data/lib/x/core/origin.rb +95 -0
- data/lib/x/core/problem.rb +302 -0
- data/lib/x/core/proxy_setting.rb +22 -0
- data/lib/x/core/rate_limit.rb +137 -0
- data/lib/x/core/rate_limit_handler.rb +166 -0
- data/lib/x/core/redirect_handler.rb +208 -0
- data/lib/x/core/refresh_reporter.rb +92 -0
- data/lib/x/core/request_builder.rb +155 -0
- data/lib/x/core/request_context.rb +85 -0
- data/lib/x/core/request_encoding.rb +135 -0
- data/lib/x/core/response.rb +196 -0
- data/lib/x/core/response_headers.rb +31 -0
- data/lib/x/core/response_parser.rb +155 -0
- data/lib/x/core/retry_handler.rb +149 -0
- data/lib/x/core/setting_validator.rb +399 -0
- data/lib/x/core/stream_body.rb +77 -0
- data/lib/x/core/stream_response.rb +267 -0
- data/lib/x/core/token_endpoint.rb +238 -0
- data/lib/x/core/version.rb +25 -0
- data/lib/x/core.rb +6 -0
- data/sig/manifest.yaml +8 -0
- data/sig/x-core.rbs +396 -0
- 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
|