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,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rate_limit"
|
|
4
|
+
require_relative "response_headers"
|
|
5
|
+
require_relative "stream_body"
|
|
6
|
+
|
|
7
|
+
module X
|
|
8
|
+
module Core
|
|
9
|
+
# The response of a request whose body is read as it arrives, which Client#get_stream passes to its block
|
|
10
|
+
#
|
|
11
|
+
# It is passed to the block before its body is read, so it reads the status and headers the API answered with,
|
|
12
|
+
# and the block reads the body with {#read_body}, for as long as it likes. It is x-core's own, rather than the
|
|
13
|
+
# response of the library the client sends its requests with, so that a block written for one release of 1.x
|
|
14
|
+
# reads the response of every other, whatever sends the request.
|
|
15
|
+
#
|
|
16
|
+
# @api public
|
|
17
|
+
class ::X::StreamResponse
|
|
18
|
+
include ResponseHeaders
|
|
19
|
+
|
|
20
|
+
# The message of the error a second read of the body raises
|
|
21
|
+
READ_ONCE = "The body of a stream is read once, and read_body has read it, whole or in part. Only a body " \
|
|
22
|
+
"read whole, without a block, is returned again"
|
|
23
|
+
# The message of the error a read of the body raises once the block of get_stream has returned
|
|
24
|
+
READ_OUTSIDE_BLOCK = "The body of a stream is read inside the block of get_stream, which has returned, and " \
|
|
25
|
+
"closed the connection the body is read from"
|
|
26
|
+
# The message of the error a read of a body that was read from the response of the transport raises
|
|
27
|
+
READ_ELSEWHERE = "The body of the stream was read from its http_response, which leaves read_body none to read"
|
|
28
|
+
# The message of the error a read of the body of a response that was frozen deeply raises
|
|
29
|
+
READ_DEEP_FROZEN = "The body of a stream cannot be read from a response that was frozen deeply, with what " \
|
|
30
|
+
"its reads are noted in and the connection the body is read from. A response frozen with freeze is read as " \
|
|
31
|
+
"any other"
|
|
32
|
+
# The end of the message of the IOError Net::HTTP raises for a body it is asked to read again
|
|
33
|
+
TRANSPORT_READ_TWICE = "#read_body called twice"
|
|
34
|
+
private_constant :READ_ONCE, :READ_OUTSIDE_BLOCK, :READ_ELSEWHERE, :READ_DEEP_FROZEN, :TRANSPORT_READ_TWICE
|
|
35
|
+
|
|
36
|
+
# What was read of the body of a response, which the response and its copies share
|
|
37
|
+
#
|
|
38
|
+
# It is an object of its own, rather than the state of the response, so that a response that was frozen counts
|
|
39
|
+
# its reads as any other does, and a dup or clone of a response counts them with it: the body they read is one.
|
|
40
|
+
#
|
|
41
|
+
# @api private
|
|
42
|
+
class ReadState
|
|
43
|
+
# Whether read_body has read the body, whole or in part
|
|
44
|
+
# @api private
|
|
45
|
+
# @return [true, nil] true once the body was read
|
|
46
|
+
attr_accessor :read
|
|
47
|
+
|
|
48
|
+
# Whether read_body has read the body whole, to return it again
|
|
49
|
+
# @api private
|
|
50
|
+
# @return [true, nil] true once the body was read whole
|
|
51
|
+
attr_accessor :whole
|
|
52
|
+
|
|
53
|
+
# Whether the block of Client#get_stream has returned
|
|
54
|
+
#
|
|
55
|
+
# The connection the body is read from is closed once it has.
|
|
56
|
+
#
|
|
57
|
+
# @api private
|
|
58
|
+
# @return [true, nil] true once the block has returned
|
|
59
|
+
attr_accessor :left
|
|
60
|
+
|
|
61
|
+
# The body read whole
|
|
62
|
+
# @api private
|
|
63
|
+
# @return [String, nil] the body, or nil for one not read whole, or a response without one
|
|
64
|
+
attr_accessor :body
|
|
65
|
+
end
|
|
66
|
+
private_constant :ReadState
|
|
67
|
+
|
|
68
|
+
# @!method headers
|
|
69
|
+
# The headers of the response
|
|
70
|
+
#
|
|
71
|
+
# The names are lowercase, and a field the API sent more than once is joined with a comma.
|
|
72
|
+
# @api public
|
|
73
|
+
# @return [Hash{String => String}] the headers, frozen
|
|
74
|
+
# @example Read the content type of a stream
|
|
75
|
+
# response.headers["content-type"]
|
|
76
|
+
|
|
77
|
+
# The URI of the request
|
|
78
|
+
# @api public
|
|
79
|
+
# @return [URI::Generic] the request URI
|
|
80
|
+
# @example Get the path of the request
|
|
81
|
+
# response.uri.path # => "/2/tweets/sample/stream"
|
|
82
|
+
attr_reader :uri
|
|
83
|
+
|
|
84
|
+
# The response itself, as the client received it, with its body not yet read
|
|
85
|
+
#
|
|
86
|
+
# It is an escape hatch, for what this does not read: the status is {#status}, the headers are {#headers}, and
|
|
87
|
+
# the body is read with {#read_body}. It is the response of the transport the client sends its requests with, a
|
|
88
|
+
# Net::HTTPResponse today, and its class is not covered by the compatibility promise of 1.x: a later release of
|
|
89
|
+
# 1.x may send its requests with another library, whose response this then returns. A body read from it, rather
|
|
90
|
+
# than with {#read_body}, raises the errors of its socket as they are, rather than as a NetworkError, which
|
|
91
|
+
# Client#get_stream raises as the block's own, and is not known to {#read_body}, which counts the reads of its
|
|
92
|
+
# own alone: once the body was read from it, in part or whole, {#read_body} reads what is left of it, returns
|
|
93
|
+
# the body it read whole, or raises an Error for a body it left none of.
|
|
94
|
+
#
|
|
95
|
+
# @api public
|
|
96
|
+
# @return [Net::HTTPResponse] the response of the transport
|
|
97
|
+
# @example Read the reason phrase of the status line
|
|
98
|
+
# response.http_response.message # => "OK"
|
|
99
|
+
attr_reader :http_response
|
|
100
|
+
|
|
101
|
+
# Initialize the response of a stream
|
|
102
|
+
#
|
|
103
|
+
# Internal to x-core: it takes the Net::HTTP response of a request, so that it can change within 1.x, as that
|
|
104
|
+
# response may, and is private, so the stream Client#get_stream opens builds it with __send__.
|
|
105
|
+
#
|
|
106
|
+
# @api private
|
|
107
|
+
# @param http_response [Net::HTTPResponse] the successful response, whose body is not yet read
|
|
108
|
+
# @param uri [URI::Generic] the URI of the request
|
|
109
|
+
# @return [StreamResponse] a new instance
|
|
110
|
+
# @example Build the response of a stream
|
|
111
|
+
# X::StreamResponse.__send__(:new, http_response:, uri: URI("https://api.x.com/2/tweets/sample/stream"))
|
|
112
|
+
def initialize(http_response:, uri:)
|
|
113
|
+
@http_response = http_response
|
|
114
|
+
@uri = uri
|
|
115
|
+
@state = ReadState.new
|
|
116
|
+
end
|
|
117
|
+
private_class_method :new
|
|
118
|
+
|
|
119
|
+
# The HTTP status code
|
|
120
|
+
#
|
|
121
|
+
# @api public
|
|
122
|
+
# @return [Integer] the status code
|
|
123
|
+
# @example Get the status code
|
|
124
|
+
# response.status # => 200
|
|
125
|
+
def status = Integer(http_response.code)
|
|
126
|
+
|
|
127
|
+
# The rate limits the response reports in its headers
|
|
128
|
+
#
|
|
129
|
+
# @api public
|
|
130
|
+
# @return [Array<RateLimit>] the 15-minute limit, and the 24-hour app and user limits when reported
|
|
131
|
+
# @example Print how many connections remain in each window
|
|
132
|
+
# response.rate_limits.each { |limit| puts "#{limit.type}: #{limit.remaining}" }
|
|
133
|
+
def rate_limits = RateLimit.__send__(:all_from, http_response)
|
|
134
|
+
|
|
135
|
+
# The 15-minute rate limit of the endpoint
|
|
136
|
+
#
|
|
137
|
+
# For a stream, it counts the connections that may be opened in the window.
|
|
138
|
+
#
|
|
139
|
+
# @api public
|
|
140
|
+
# @return [RateLimit, nil] the rate limit, or nil if the response reports none
|
|
141
|
+
# @example Read how many times a stream may connect again before the limit resets
|
|
142
|
+
# response.rate_limit&.remaining # => 49
|
|
143
|
+
def rate_limit = rate_limits.find { |limit| limit.type.eql?(RateLimit::RATE_LIMIT_TYPE) }
|
|
144
|
+
|
|
145
|
+
# Read the body, a chunk at a time as it arrives, or whole
|
|
146
|
+
#
|
|
147
|
+
# Given a block, each chunk of the body is passed to it as it arrives, until the body ends, which the body of a
|
|
148
|
+
# stream does only when its connection closes. A chunk is whatever arrived, in binary, since it may end within a
|
|
149
|
+
# line, or within a character, which no encoding but binary reads half of. Without a block, the body is read to
|
|
150
|
+
# its end, and returned whole, tagged UTF-8, the encoding of the JSON the API sends, as the body of a
|
|
151
|
+
# {Response} is: a body that is not valid UTF-8 keeps its bytes, so valid_encoding? tells it apart, and scrub
|
|
152
|
+
# replaces what is not UTF-8. A response without a body, such as a 204, has none to return.
|
|
153
|
+
#
|
|
154
|
+
# The body is read once, inside the block of Client#get_stream, since it is read from a connection that is
|
|
155
|
+
# closed once that block returns. A body read whole, without a block, is returned again by each read without a
|
|
156
|
+
# block that follows, as a String of its own. Any other second read raises an Error that says so, with or
|
|
157
|
+
# without a block, after a read that passed the chunks to a block, even one the block left partway through, with
|
|
158
|
+
# break, throw, or an error, or after a read that failed; so does any read once the block of Client#get_stream
|
|
159
|
+
# has returned. The Error is the caller's, not the socket's, so it is neither a NetworkError nor retried. Only
|
|
160
|
+
# the reads of this method are counted: a body read from {#http_response} is not known to it, and a read of a
|
|
161
|
+
# body that was read to its end there, a chunk at a time, raises the same Error, for there is none left. A dup or
|
|
162
|
+
# clone of the response reads the same body, so its reads are counted with those of the response, and a
|
|
163
|
+
# response that was frozen is read as any other is. A response that was frozen deeply, as Ractor.make_shareable
|
|
164
|
+
# freezes one on a Ruby before 4.1, which refuses to share the socket of one, is not: its connection is frozen
|
|
165
|
+
# with it, so a read of a body not yet read raises an Error that says so, while a body read whole before is
|
|
166
|
+
# returned again.
|
|
167
|
+
#
|
|
168
|
+
# An error of the socket the body is read from, such as a connection that dropped, or a read that timed out,
|
|
169
|
+
# raises a NetworkError here, inside the block of Client#get_stream, which may rescue it, and which
|
|
170
|
+
# Client#get_stream raises as it is otherwise. It names the request, and its cause is the error of the socket.
|
|
171
|
+
# An error the block raises is raised as it was, whatever its class.
|
|
172
|
+
#
|
|
173
|
+
# @api public
|
|
174
|
+
# @yieldparam chunk [String] each chunk of the body, as it arrives, in binary
|
|
175
|
+
# @return [String, nil] the body, read whole and tagged UTF-8, or nil for a response without a body, or once a
|
|
176
|
+
# block was passed each chunk of it
|
|
177
|
+
# @raise [Error] if the body was read before, unless both reads are whole, or the block of Client#get_stream
|
|
178
|
+
# has returned, or the body was read from {#http_response}, or the response was frozen deeply
|
|
179
|
+
# @raise [NetworkError] if the socket the body is read from fails, as when the connection drops or a read
|
|
180
|
+
# times out
|
|
181
|
+
# @raise [StandardError] whatever the block raises
|
|
182
|
+
# @example Print the body of a stream as it arrives
|
|
183
|
+
# response.read_body { |chunk| print chunk }
|
|
184
|
+
# @example Read a body that ends whole
|
|
185
|
+
# body = response.read_body
|
|
186
|
+
# @example Flush what was read of a stream that dropped
|
|
187
|
+
# begin
|
|
188
|
+
# response.read_body { |chunk| file.write(chunk) }
|
|
189
|
+
# rescue X::NetworkError
|
|
190
|
+
# file.flush
|
|
191
|
+
# raise
|
|
192
|
+
# end
|
|
193
|
+
def read_body(&block)
|
|
194
|
+
raise Error, READ_OUTSIDE_BLOCK if @state.left
|
|
195
|
+
return whole_body if @state.whole && !block
|
|
196
|
+
note_read
|
|
197
|
+
failed = [] #: Array[Exception]
|
|
198
|
+
body = StreamBody.reading(uri, failed) { transport_body(block, failed) }
|
|
199
|
+
return if block
|
|
200
|
+
|
|
201
|
+
@state.body = body
|
|
202
|
+
@state.whole = true
|
|
203
|
+
whole_body
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
private
|
|
207
|
+
|
|
208
|
+
# Note that the body is read, which it is once
|
|
209
|
+
#
|
|
210
|
+
# A response that was frozen deeply, with what its reads are noted in, has nowhere to note it, and no
|
|
211
|
+
# connection to read from that Net::HTTP can read, so its read is refused, rather than raise a FrozenError that
|
|
212
|
+
# names what the reads are counted in.
|
|
213
|
+
#
|
|
214
|
+
# @api private
|
|
215
|
+
# @return [void]
|
|
216
|
+
# @raise [Error] if the body was read before, or the response was frozen deeply
|
|
217
|
+
def note_read
|
|
218
|
+
raise Error, READ_ONCE if @state.read
|
|
219
|
+
raise Error, READ_DEEP_FROZEN if @state.frozen?
|
|
220
|
+
|
|
221
|
+
@state.read = true
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# The body read whole, as a String of its own, tagged UTF-8
|
|
225
|
+
#
|
|
226
|
+
# @api private
|
|
227
|
+
# @return [String, nil] the body, or nil for a response without one
|
|
228
|
+
def whole_body = @state.body.dup&.force_encoding(Encoding::UTF_8)
|
|
229
|
+
|
|
230
|
+
# Read the body from the response of the transport
|
|
231
|
+
#
|
|
232
|
+
# The transport knows nothing of the reads this counts, nor this of the transport's, so a body the block of
|
|
233
|
+
# Client#get_stream read from {#http_response} is found out here: Net::HTTP raises an IOError for a body it is
|
|
234
|
+
# asked to pass to a block again, which is not an error of the socket, and returns what it passed the chunks to,
|
|
235
|
+
# rather than a String, for one it is asked to return whole.
|
|
236
|
+
#
|
|
237
|
+
# @api private
|
|
238
|
+
# @param block [Proc, nil] the block each chunk is passed to, or nil to read the body whole
|
|
239
|
+
# @param failed [Array<Exception>] the errors of the block, which the error it raises is added to
|
|
240
|
+
# @return [String, nil] the body read whole, or nil for a response without one, or what a block was passed
|
|
241
|
+
# @raise [Error] if the body was read from the response of the transport
|
|
242
|
+
# @raise [StandardError] whatever reading the body raises
|
|
243
|
+
def transport_body(block, failed)
|
|
244
|
+
return http_response.read_body(&StreamBody.passing(block, failed)) if block
|
|
245
|
+
|
|
246
|
+
http_response.read_body.tap { |body| raise Error, READ_ELSEWHERE unless body.nil? || body.instance_of?(String) }
|
|
247
|
+
rescue IOError => e
|
|
248
|
+
raise unless e.message.end_with?(TRANSPORT_READ_TWICE) && !failed.include?(e)
|
|
249
|
+
|
|
250
|
+
raise Error, READ_ELSEWHERE
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Note that the block of Client#get_stream has returned
|
|
254
|
+
#
|
|
255
|
+
# The body is read no more after it. Internal to x-core: the stream Client#get_stream opens calls it with
|
|
256
|
+
# __send__ once its block ends, however it ends, since the connection the body is read from is closed then. It
|
|
257
|
+
# is noted in the state the response shares with its copies, so it is noted of a response that was frozen, and
|
|
258
|
+
# of each copy of it.
|
|
259
|
+
#
|
|
260
|
+
# @api private
|
|
261
|
+
# @return [void]
|
|
262
|
+
def leave
|
|
263
|
+
@state.left = true
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "simple_oauth"
|
|
6
|
+
require "uri"
|
|
7
|
+
require_relative "credential_validator"
|
|
8
|
+
require_relative "errors/authorization_error"
|
|
9
|
+
require_relative "errors/invalid_response"
|
|
10
|
+
require_relative "oauth2_tokens"
|
|
11
|
+
require_relative "authenticator"
|
|
12
|
+
require_relative "request_builder"
|
|
13
|
+
require_relative "request_context"
|
|
14
|
+
require_relative "response_parser"
|
|
15
|
+
|
|
16
|
+
module X
|
|
17
|
+
module Core
|
|
18
|
+
# Sends the token requests that simple_oauth builds over a client's connection
|
|
19
|
+
#
|
|
20
|
+
# Internal to x-core: the app-only and OAuth 2.0 authenticators, and OAuth2Authorization, fetch tokens with it,
|
|
21
|
+
# so that the proxy, timeouts, debug output, and headers of a client apply to token requests too.
|
|
22
|
+
#
|
|
23
|
+
# @api private
|
|
24
|
+
module TokenEndpoint
|
|
25
|
+
extend self
|
|
26
|
+
|
|
27
|
+
# The responses that refuse a token request whatever their body, as RFC 6749 refuses one
|
|
28
|
+
REFUSALS = [Net::HTTPBadRequest, Net::HTTPUnauthorized].freeze
|
|
29
|
+
private_constant :REFUSALS
|
|
30
|
+
|
|
31
|
+
# The classes of the responses that answer a token request, with a token or a refusal, when their body is JSON
|
|
32
|
+
ANSWERS = [Net::HTTPSuccess, Net::HTTPClientError].freeze
|
|
33
|
+
private_constant :ANSWERS
|
|
34
|
+
|
|
35
|
+
# The version of the API that ends the path of a base URL, such as the /2/ of https://api.x.com/2/
|
|
36
|
+
API_VERSION = %r{/\d+(?:\.\d+)*/?\z}
|
|
37
|
+
private_constant :API_VERSION
|
|
38
|
+
|
|
39
|
+
# The message of the error raised for a token a successful response holds that cannot be read as one
|
|
40
|
+
UNREADABLE_TOKEN = "The token endpoint answered with a token that cannot be read"
|
|
41
|
+
private_constant :UNREADABLE_TOKEN
|
|
42
|
+
|
|
43
|
+
# The URL of a token endpoint at a base URL, under the path it serves the API at
|
|
44
|
+
#
|
|
45
|
+
# A token endpoint of X is requested at the scheme, host, and port of the base URL of the client that sends its
|
|
46
|
+
# requests, so that a client pointed at another host, such as a test server or a recording proxy, sends its
|
|
47
|
+
# credentials there, as it sends its requests, rather than to X. The path is the one X serves the endpoint at,
|
|
48
|
+
# under the path the base URL serves the API at, which is its path without the version of the API that ends it,
|
|
49
|
+
# so that a gateway that serves the API under a path of its own, as https://gateway.example/x/2/ serves it under
|
|
50
|
+
# /x, serves the token endpoints under that path too, as X serves them beside the API.
|
|
51
|
+
#
|
|
52
|
+
# @api private
|
|
53
|
+
# @param base_url [String] the base URL of the client
|
|
54
|
+
# @param token_url [String] the URL X serves the endpoint at
|
|
55
|
+
# @return [String] the URL of the endpoint at the origin of the base URL
|
|
56
|
+
# @example The token endpoint of a client pointed at a test server
|
|
57
|
+
# X::Core::TokenEndpoint.url_at("http://localhost:3000/2/", X::AppOnlyAuthenticator::TOKEN_URL)
|
|
58
|
+
# # => "http://localhost:3000/oauth2/token"
|
|
59
|
+
# @example The token endpoint of a client pointed at a gateway that serves the API under a path
|
|
60
|
+
# X::Core::TokenEndpoint.url_at("https://gateway.example/x/2/", X::OAuth2Authenticator::TOKEN_URL)
|
|
61
|
+
# # => "https://gateway.example/x/2/oauth2/token"
|
|
62
|
+
def url_at(base_url, token_url)
|
|
63
|
+
base_path, path = URI(base_url).path, URI(token_url).path #: [String, String]
|
|
64
|
+
mount = base_path.sub(API_VERSION, "").chomp("/")
|
|
65
|
+
String(URI.join(base_url, "#{mount}#{path}"))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The scopes a token names
|
|
69
|
+
#
|
|
70
|
+
# A token response names the scopes it granted as one String, each scope apart from the next by a space.
|
|
71
|
+
#
|
|
72
|
+
# @api private
|
|
73
|
+
# @param token [SimpleOAuth::OAuth2::Token] the token the endpoint returned
|
|
74
|
+
# @return [Array<String>, nil] the scopes, frozen, or nil if the token names none
|
|
75
|
+
# @example Read the scopes of a token
|
|
76
|
+
# X::Core::TokenEndpoint.scopes_of(token) # => ["tweet.read", "users.read", "offline.access"]
|
|
77
|
+
def scopes_of(token)
|
|
78
|
+
scopes = String.try_convert(token.scope).to_s.split
|
|
79
|
+
CredentialValidator.frozen_scopes(scopes) unless scopes.empty?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Send a token request and read the token the endpoint returns
|
|
83
|
+
#
|
|
84
|
+
# The endpoint answers a token request as OAuth 2.0 does: with the token in a successful response of JSON, or
|
|
85
|
+
# with a refusal of the request, which raises AuthorizationError, in a response of 400 Bad Request or 401
|
|
86
|
+
# Unauthorized, or of another status of 4xx whose body is JSON, as X refuses the API key and secret of an app
|
|
87
|
+
# with a 403 Forbidden. Any other response says that the endpoint failed to answer rather than that it refused the credentials, as a
|
|
88
|
+
# redirect does, or the page of a proxy, firewall, or captive portal, and so does a response of 429 Too Many
|
|
89
|
+
# Requests, or of a server error. It raises the error a response of the API with that status raises: the
|
|
90
|
+
# HTTPError of a status that is not successful, which a client waits out or retries as it does that response,
|
|
91
|
+
# and InvalidResponse for a successful one whose body is not JSON, or holds no token, rather than
|
|
92
|
+
# AuthorizationError, which tells a caller to ask the user to authorize the app again, which a failure of the
|
|
93
|
+
# network is no reason to.
|
|
94
|
+
#
|
|
95
|
+
# @api private
|
|
96
|
+
# @param token_request [SimpleOAuth::OAuth2::Request] the token request
|
|
97
|
+
# @param connection [Connection] the connection to send it over
|
|
98
|
+
# @param refusal [String] the message of a refusal that describes no reason
|
|
99
|
+
# @param headers [Hash{String => String}] the headers of the client that sends it; see {#post}
|
|
100
|
+
# @return [SimpleOAuth::OAuth2::Token] the token
|
|
101
|
+
# @raise [TooManyRequests] if the endpoint limits the rate of the request
|
|
102
|
+
# @raise [HTTPError] if the endpoint fails to answer, as a server error, a redirect, or a refusal whose body is
|
|
103
|
+
# not JSON says
|
|
104
|
+
# @raise [InvalidResponse] if the endpoint answers successfully with a body that is not JSON, or holds no token
|
|
105
|
+
# @raise [AuthorizationError] if the endpoint refuses the request, with the response that refused it
|
|
106
|
+
# @example Refresh a token
|
|
107
|
+
# X::Core::TokenEndpoint.fetch(oauth2_client.refresh_token_request(refresh_token:), connection:,
|
|
108
|
+
# refusal: "Token refresh failed", headers: client.headers)
|
|
109
|
+
def fetch(token_request, connection:, refusal:, headers:)
|
|
110
|
+
request = post(token_request, headers)
|
|
111
|
+
response = connection.perform(request:)
|
|
112
|
+
raise failure(response, request) unless answer?(response)
|
|
113
|
+
|
|
114
|
+
token_of(response, request, refusal)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# Check whether the endpoint answered as OAuth 2.0 does, with a token or a refusal
|
|
120
|
+
#
|
|
121
|
+
# A refusal is a response of a status REFUSALS names, whatever its body, or of another status of 4xx, other than
|
|
122
|
+
# 429 Too Many Requests, whose body is JSON.
|
|
123
|
+
#
|
|
124
|
+
# @api private
|
|
125
|
+
# @param response [Net::HTTPResponse] the response of the endpoint
|
|
126
|
+
# @return [Boolean] true for a successful response of JSON, or a refusal
|
|
127
|
+
def answer?(response)
|
|
128
|
+
return true if REFUSALS.include?(response.class)
|
|
129
|
+
|
|
130
|
+
ANSWERS.any? { |answer| response.is_a?(answer) } && !response.instance_of?(Net::HTTPTooManyRequests) &&
|
|
131
|
+
json_object?(response.body)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Check whether a body is a JSON object
|
|
135
|
+
#
|
|
136
|
+
# A response of no content, such as a 204 a gateway answers with, has no body, which is not one.
|
|
137
|
+
#
|
|
138
|
+
# @api private
|
|
139
|
+
# @param body [String, nil] the body of the response
|
|
140
|
+
# @return [Boolean] true if the body parses to a Hash
|
|
141
|
+
def json_object?(body)
|
|
142
|
+
JSON.parse(body.to_s).instance_of?(Hash)
|
|
143
|
+
rescue JSON::ParserError
|
|
144
|
+
false
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The token a response of the endpoint holds
|
|
148
|
+
#
|
|
149
|
+
# The error raised in place of it is raised with the cause of the failure simple_oauth reports, rather than the
|
|
150
|
+
# failure, so that its cause is the error of x-core it was raised in rescue of, such as the Unauthorized that led
|
|
151
|
+
# a client to refresh, and nil for none.
|
|
152
|
+
#
|
|
153
|
+
# @api private
|
|
154
|
+
# @param response [Net::HTTPResponse] the response of the endpoint, a token or a refusal
|
|
155
|
+
# @param request [Net::HTTP::Post] the token request, which the error names
|
|
156
|
+
# @param refusal [String] the message of a refusal that describes no reason
|
|
157
|
+
# @return [SimpleOAuth::OAuth2::Token] the token
|
|
158
|
+
# @raise [AuthorizationError] if the response refuses the request
|
|
159
|
+
# @raise [InvalidResponse] if the response is successful, but holds no token, or one that cannot be read
|
|
160
|
+
def token_of(response, request, refusal)
|
|
161
|
+
SimpleOAuth::OAuth2::Token.from_response(status: response.code, body: response.body).tap { |token| readable!(token) }
|
|
162
|
+
rescue SimpleOAuth::OAuth2::Error => e
|
|
163
|
+
raise refused(e, response, request, refusal), cause: e.cause
|
|
164
|
+
rescue ArgumentError
|
|
165
|
+
raise InvalidResponse.new(UNREADABLE_TOKEN, http_response: response, **RequestContext.of(request))
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Check that a token can be read as the tokens a client holds
|
|
169
|
+
#
|
|
170
|
+
# It is checked before a client takes any of it. A refresh token that is not a String, an access token that is
|
|
171
|
+
# blank or holds a line break, or scopes that are not scope tokens, is not a token X documents, and taking it
|
|
172
|
+
# would leave the client holding what it cannot send, or report to save_tokens.
|
|
173
|
+
#
|
|
174
|
+
# @api private
|
|
175
|
+
# @param token [SimpleOAuth::OAuth2::Token] the token
|
|
176
|
+
# @return [void]
|
|
177
|
+
# @raise [ArgumentError] if the token cannot be read so
|
|
178
|
+
def readable!(token)
|
|
179
|
+
raise ArgumentError if token.access_token.match?(/[\r\n]/)
|
|
180
|
+
|
|
181
|
+
OAuth2Tokens.new(access_token: token.access_token, refresh_token: token.refresh_token, scopes: scopes_of(token))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Create the error of a response that holds no token
|
|
185
|
+
#
|
|
186
|
+
# Its message is the reason X described, or else the error code it reported, or else the message given.
|
|
187
|
+
#
|
|
188
|
+
# @api private
|
|
189
|
+
# @param error [SimpleOAuth::OAuth2::Error] the failure simple_oauth reports of the response
|
|
190
|
+
# @param response [Net::HTTPResponse] the response of the endpoint
|
|
191
|
+
# @param request [Net::HTTP::Post] the token request, which the error names
|
|
192
|
+
# @param refusal [String] the message of a refusal that describes no reason
|
|
193
|
+
# @return [AuthorizationError, InvalidResponse] the error of a refusal, or InvalidResponse for a successful
|
|
194
|
+
# response that holds no token
|
|
195
|
+
def refused(error, response, request, refusal)
|
|
196
|
+
message = error.description || error.code || refusal
|
|
197
|
+
return InvalidResponse.new(message, http_response: response, **RequestContext.of(request)) if response.is_a?(Net::HTTPSuccess)
|
|
198
|
+
|
|
199
|
+
AuthorizationError.new(message, http_response: response, **RequestContext.of(request))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Create the error of a response in which the endpoint failed to answer
|
|
203
|
+
# @api private
|
|
204
|
+
# @param response [Net::HTTPResponse] the response of the endpoint
|
|
205
|
+
# @param request [Net::HTTP::Post] the token request, which the error names
|
|
206
|
+
# @return [HTTPError, InvalidResponse] the error of the status of the response, or InvalidResponse for a
|
|
207
|
+
# successful one
|
|
208
|
+
def failure(response, request)
|
|
209
|
+
return ResponseParser.new.error(response, request) unless response.is_a?(Net::HTTPSuccess)
|
|
210
|
+
|
|
211
|
+
InvalidResponse.new(http_response: response, **RequestContext.of(request))
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Build the POST that sends a token request
|
|
215
|
+
#
|
|
216
|
+
# It is sent with the headers a request of the API is sent with: the User-Agent of the gem, and the headers of
|
|
217
|
+
# the client, which replace it, so that a gateway the base URL names, which may require a header of its own, is
|
|
218
|
+
# sent it with the token requests of the client too. The headers of the token request itself replace both, as
|
|
219
|
+
# its credentials and its form content type, and the client's Authorization header is never sent, since a token
|
|
220
|
+
# request carries its own credentials, or none, as the refresh of a public client does.
|
|
221
|
+
#
|
|
222
|
+
# @api private
|
|
223
|
+
# @param token_request [SimpleOAuth::OAuth2::Request] the token request
|
|
224
|
+
# @param headers [Hash{String => String}] the headers of the client that sends it
|
|
225
|
+
# @return [Net::HTTP::Post] the request
|
|
226
|
+
def post(token_request, headers)
|
|
227
|
+
request = Net::HTTP::Post.new(URI(token_request.url))
|
|
228
|
+
client_headers = headers.reject { |name, _| name.casecmp?(Authenticator::AUTHENTICATION_HEADER) }
|
|
229
|
+
[RequestBuilder::DEFAULT_HEADERS, client_headers, token_request.headers].each do |sent|
|
|
230
|
+
sent.each { |name, value| request[name] = value }
|
|
231
|
+
end
|
|
232
|
+
request.body = token_request.body
|
|
233
|
+
request
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
private_constant :TokenEndpoint
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems/version"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# The HTTP layer of the X gem
|
|
7
|
+
# @api public
|
|
8
|
+
module Core
|
|
9
|
+
# The current version of the x-core gem
|
|
10
|
+
# @api public
|
|
11
|
+
VERSION = "1.0.0"
|
|
12
|
+
|
|
13
|
+
# The version as a Gem::Version, which compares one release with another
|
|
14
|
+
#
|
|
15
|
+
# VERSION is a String, as a version constant is throughout Ruby, so that what reads it can split it, match it,
|
|
16
|
+
# or send it wherever a String belongs, such as the User-Agent of a request. This builds the Gem::Version that
|
|
17
|
+
# compares it with another version, which a String compares by character rather than by segment.
|
|
18
|
+
#
|
|
19
|
+
# @api public
|
|
20
|
+
# @return [Gem::Version] the version
|
|
21
|
+
# @example Take a path that a later release opened
|
|
22
|
+
# X::Core.gem_version >= Gem::Version.new("1.1")
|
|
23
|
+
def self.gem_version = Gem::Version.new(VERSION)
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/x/core.rb
ADDED
data/sig/manifest.yaml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# The standard libraries the signatures of x-core refer to, which rbs collection loads for code that depends on it
|
|
2
|
+
#
|
|
3
|
+
# rbs collection reads every library named here as a standard library, so the gems x-core depends on are left to its
|
|
4
|
+
# gemspec, from which it installs their signatures.
|
|
5
|
+
dependencies:
|
|
6
|
+
- name: json
|
|
7
|
+
- name: net-http
|
|
8
|
+
- name: uri
|