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,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "zlib"
|
|
6
|
+
require_relative "request_builder"
|
|
7
|
+
|
|
8
|
+
module X
|
|
9
|
+
module Core
|
|
10
|
+
# How a connection sends one request, and what it counts as a failure of the network, included into Connection
|
|
11
|
+
# @api private
|
|
12
|
+
module ConnectionRequest
|
|
13
|
+
# Network errors that should be wrapped in NetworkError
|
|
14
|
+
#
|
|
15
|
+
# IOError covers EOFError, and a read from a socket closed under it. SystemCallError covers every error the
|
|
16
|
+
# operating system reports for a socket, such as a refused, reset, or aborted connection, a network that is down
|
|
17
|
+
# or has no route, or a write refused with EPIPE. The open, read, and write timeouts are those of Net::HTTP, and
|
|
18
|
+
# no other Timeout::Error, such as one Timeout.timeout raises around a request, which is raised as it is, so that
|
|
19
|
+
# a rescue of it still catches it, and no handler sends the request again for it. A connection cut off
|
|
20
|
+
# mid-response can leave Net::HTTP a status line it cannot parse, or a compressed body that Zlib cannot inflate,
|
|
21
|
+
# and a proxy that refuses to open a tunnel, such as with 407 Proxy Authentication Required, raises a
|
|
22
|
+
# Net::ProtocolError. A response whose Content-Length or Content-Range Net::HTTP cannot read raises a
|
|
23
|
+
# Net::HTTPHeaderSyntaxError.
|
|
24
|
+
NETWORK_ERRORS = [
|
|
25
|
+
IOError,
|
|
26
|
+
Net::HTTPBadResponse,
|
|
27
|
+
Net::HTTPHeaderSyntaxError,
|
|
28
|
+
Net::ProtocolError,
|
|
29
|
+
OpenSSL::SSL::SSLError,
|
|
30
|
+
SocketError,
|
|
31
|
+
SystemCallError,
|
|
32
|
+
Net::OpenTimeout,
|
|
33
|
+
Net::ReadTimeout,
|
|
34
|
+
Net::WriteTimeout,
|
|
35
|
+
Zlib::Error
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
# Errors that say a connection kept open had been closed by the time a request was sent on it
|
|
39
|
+
#
|
|
40
|
+
# EOFError is read from a socket the peer closed, and a write to one it closed or reset is refused with EPIPE,
|
|
41
|
+
# ECONNRESET, or ECONNABORTED. A timeout is not among them: a request that timed out waiting for its response
|
|
42
|
+
# may have reached the API, which may have acted on it. Nor is any of them once the response has begun: a body
|
|
43
|
+
# cut off as it is read is one the API sent.
|
|
44
|
+
STALE_CONNECTION_ERRORS = [EOFError, Errno::ECONNABORTED, Errno::ECONNRESET, Errno::EPIPE].freeze
|
|
45
|
+
private_constant :NETWORK_ERRORS, :STALE_CONNECTION_ERRORS
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Send a request, once more on a new connection when a kept one had gone stale
|
|
50
|
+
#
|
|
51
|
+
# X, or a proxy between, can close a connection that is being kept open for the next request, and the request
|
|
52
|
+
# that takes it then fails as it is written or its response is first read, as one of STALE_CONNECTION_ERRORS.
|
|
53
|
+
# That request never reached the API, so an idempotent one is sent again, on a connection opened for it rather
|
|
54
|
+
# than taken from the pool, where another connection may have gone stale too. A request that failed any other
|
|
55
|
+
# way, such as by timing out, or on a connection opened for it, is not sent again: nothing about it says the API
|
|
56
|
+
# did not act on it. Nor is one whose response had begun, which Net::HTTP says by passing it to the block of
|
|
57
|
+
# the request once its status and headers are read: a connection that drops as the body is read was not stale,
|
|
58
|
+
# and the API answered the request, so it raises as a read that timed out does. Net::HTTP would send a request
|
|
59
|
+
# again of its own, after a timeout as well, with the OAuth 1.0a nonce and signature of the attempt that failed,
|
|
60
|
+
# which is why its retries are turned off and the request the caller built is sent again here.
|
|
61
|
+
#
|
|
62
|
+
# @api private
|
|
63
|
+
# @param request [Net::HTTPRequest] the HTTP request to send
|
|
64
|
+
# @param key [Array] the scheme, host, and port to connect to
|
|
65
|
+
# @param open [Proc] builds a connection to the host
|
|
66
|
+
# @return [Net::HTTPResponse] the HTTP response
|
|
67
|
+
# @raise [StandardError] whatever the request raised, once it may not be sent again
|
|
68
|
+
def send_request(request, key, open)
|
|
69
|
+
stale = false
|
|
70
|
+
begin
|
|
71
|
+
@pool.with(key, open) do |http_client, from_pool|
|
|
72
|
+
stale = from_pool
|
|
73
|
+
http_client.request(request) { stale = false }
|
|
74
|
+
end
|
|
75
|
+
rescue *STALE_CONNECTION_ERRORS
|
|
76
|
+
raise unless stale && idempotent?(request)
|
|
77
|
+
|
|
78
|
+
@pool.with(key, open, fresh: true) { |http_client, _| http_client.request(request) }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Check whether sending a request again has the same effect as sending it once
|
|
83
|
+
# @api private
|
|
84
|
+
# @param request [Net::HTTPRequest] the HTTP request
|
|
85
|
+
# @return [Boolean] true for a GET, PUT, or DELETE
|
|
86
|
+
def idempotent?(request) = RequestBuilder.idempotent?(request.method.downcase.to_sym)
|
|
87
|
+
end
|
|
88
|
+
private_constant :ConnectionRequest
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X
|
|
4
|
+
module Core
|
|
5
|
+
# Refuses Marshal, YAML, and JSON for what holds credentials, included into a client and its internals, an
|
|
6
|
+
# authenticator, and an authorization
|
|
7
|
+
#
|
|
8
|
+
# Marshal and YAML would write the credentials such an object holds, in the clear, wherever what they write is
|
|
9
|
+
# kept, such as a cache, where a client in a Hash that is cached would carry them without a word, or the arguments
|
|
10
|
+
# of a job a queue writes as YAML. Where Marshal would not raise TypeError, for a lock the object holds, it would
|
|
11
|
+
# write them silently, and YAML writes every instance variable, a lock or not, so each refuses alike, with the
|
|
12
|
+
# TypeError Marshal raises for what it cannot write, such as a Proc, so that code that rescues it around
|
|
13
|
+
# Marshal.dump, as a cache or a deep copy does, rescues this too. JSON is refused alike, since ActiveSupport's
|
|
14
|
+
# Object#as_json reads every instance variable as YAML does, so that rendering a client, or a Hash that holds one,
|
|
15
|
+
# as JSON would write its credentials into a response or a log.
|
|
16
|
+
#
|
|
17
|
+
# Internal to x-core: the methods it gives these classes, marshal_dump, encode_with, as_json, and to_json, are
|
|
18
|
+
# public API, but the module is only how they are shared, and which classes include it can change within 1.x.
|
|
19
|
+
# They call raise and format on Kernel, so that a module included into X::Client after it, as x-resources and
|
|
20
|
+
# x-uploads are, cannot change what they raise, whatever the module defines.
|
|
21
|
+
#
|
|
22
|
+
# @api private
|
|
23
|
+
module CredentialHolder
|
|
24
|
+
# The message of the error raised for Marshal, YAML, or JSON, which names the class refused and what refused it
|
|
25
|
+
REFUSAL_MESSAGE = "%s holds credentials, which %s would write in the clear wherever it is kept; keep the " \
|
|
26
|
+
"credentials in a secret store, and the X::OAuth2Tokens save_tokens is passed, and build it again from them"
|
|
27
|
+
private_constant :REFUSAL_MESSAGE
|
|
28
|
+
|
|
29
|
+
# Refuse to be written with Marshal, which would write the credentials
|
|
30
|
+
#
|
|
31
|
+
# @api public
|
|
32
|
+
# @return [void]
|
|
33
|
+
# @raise [TypeError] always
|
|
34
|
+
# @example Store the tokens a refresh issued, rather than the client
|
|
35
|
+
# X::Client.new(**credentials, save_tokens: ->(tokens) { store.save(**tokens.to_h) })
|
|
36
|
+
def marshal_dump = Kernel.raise(TypeError, Kernel.format(REFUSAL_MESSAGE, self.class, "Marshal"))
|
|
37
|
+
|
|
38
|
+
# Refuse to be written as YAML, which would write the credentials
|
|
39
|
+
#
|
|
40
|
+
# YAML reads no marshal_dump, and writes every instance variable of an object that does not say how it is
|
|
41
|
+
# written, credentials and all, so it is refused as Marshal is.
|
|
42
|
+
#
|
|
43
|
+
# @api public
|
|
44
|
+
# @param _coder [Psych::Coder] the coder YAML would write the object with
|
|
45
|
+
# @return [void]
|
|
46
|
+
# @raise [TypeError] always
|
|
47
|
+
# @example Enqueue the identifier of a user, rather than a client, for a job its queue writes as YAML
|
|
48
|
+
# PostJob.perform_later(user_id: client.authenticator.user_id)
|
|
49
|
+
def encode_with(_coder) = Kernel.raise(TypeError, Kernel.format(REFUSAL_MESSAGE, self.class, "YAML"))
|
|
50
|
+
|
|
51
|
+
# Refuse to be read as JSON, which would write the credentials
|
|
52
|
+
#
|
|
53
|
+
# ActiveSupport's Object#as_json reads every instance variable of an object that does not say how it is read,
|
|
54
|
+
# credentials and all, so it is refused as YAML is.
|
|
55
|
+
#
|
|
56
|
+
# @api public
|
|
57
|
+
# @return [void]
|
|
58
|
+
# @raise [TypeError] always
|
|
59
|
+
# @example Render the user a client acts for, rather than the client
|
|
60
|
+
# render json: {user_id: client.authenticator.user_id}
|
|
61
|
+
def as_json(*) = Kernel.raise(TypeError, Kernel.format(REFUSAL_MESSAGE, self.class, "JSON"))
|
|
62
|
+
|
|
63
|
+
# Refuse to be written as JSON, which would write the credentials
|
|
64
|
+
#
|
|
65
|
+
# It raises as {#as_json} does, for the reason that says, so that JSON.generate refuses a client within what it
|
|
66
|
+
# writes as well.
|
|
67
|
+
#
|
|
68
|
+
# @api public
|
|
69
|
+
# @param _state [JSON::State, nil] the state JSON would write the object with
|
|
70
|
+
# @return [void]
|
|
71
|
+
# @raise [TypeError] always
|
|
72
|
+
# @example Log the user a client acts for, rather than the client
|
|
73
|
+
# logger.info(JSON.generate(user_id: client.authenticator.user_id))
|
|
74
|
+
def to_json(_state = nil) = Kernel.raise(TypeError, Kernel.format(REFUSAL_MESSAGE, self.class, "JSON"))
|
|
75
|
+
end
|
|
76
|
+
private_constant :CredentialHolder
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "authenticator"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
module Core
|
|
7
|
+
# Checks that the credentials of a new client form complete sets
|
|
8
|
+
#
|
|
9
|
+
# A client authenticates with the first complete set of credentials it has, and ignores the rest. Credentials that
|
|
10
|
+
# form no set would send requests without credentials, an access token without the rest of its set would
|
|
11
|
+
# authenticate as the app, or with a bearer token, rather than as the user it belongs to, and any other credential
|
|
12
|
+
# of a set that is not complete, such as a client ID beside a bearer token, is a mistake that a client would
|
|
13
|
+
# otherwise hide. So every credential must belong to a complete set. A client may hold several, such as the
|
|
14
|
+
# bearer token of an app beside its API key and secret, but not OAuth 2.0 credentials beside OAuth 1.0a ones, which
|
|
15
|
+
# share the access token, and which it authenticates with first, so that the OAuth 2.0 credentials would go unused.
|
|
16
|
+
# A client never changes the credentials it was built with, so this runs once, when the client is built.
|
|
17
|
+
#
|
|
18
|
+
# @api private
|
|
19
|
+
module CredentialValidator
|
|
20
|
+
extend self
|
|
21
|
+
|
|
22
|
+
# The message of the error raised for credentials that do not form a complete set
|
|
23
|
+
INCOMPLETE_CREDENTIALS = "The credentials given do not form a complete set. Pass api_key, api_key_secret, " \
|
|
24
|
+
"access_token, and access_token_secret for OAuth 1.0a; client_id and access_token, with the refresh_token " \
|
|
25
|
+
"that refreshes it and the client_secret of a confidential client, for OAuth 2.0; bearer_token for the app's " \
|
|
26
|
+
"bearer token; or api_key and api_key_secret to authenticate as the app. Leave out any credential of a set " \
|
|
27
|
+
"that is not complete"
|
|
28
|
+
private_constant :INCOMPLETE_CREDENTIALS
|
|
29
|
+
|
|
30
|
+
# The credentials of each set: OAuth 1.0a, OAuth 2.0 for a confidential and for a public client, with a refresh
|
|
31
|
+
# token and without one, as an authorization without offline.access issues them, a bearer token, and the app's
|
|
32
|
+
# API key and secret
|
|
33
|
+
CREDENTIAL_SETS = [
|
|
34
|
+
%i[api_key api_key_secret access_token access_token_secret],
|
|
35
|
+
%i[client_id client_secret access_token refresh_token],
|
|
36
|
+
%i[client_id access_token refresh_token],
|
|
37
|
+
%i[client_id client_secret access_token],
|
|
38
|
+
%i[client_id access_token],
|
|
39
|
+
%i[bearer_token],
|
|
40
|
+
%i[api_key api_key_secret]
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
# The credentials a client authenticates with before OAuth 2.0 credentials, when it holds both
|
|
44
|
+
OAUTH1_CREDENTIALS = CREDENTIAL_SETS.first
|
|
45
|
+
# The credentials of the least set of OAuth 2.0, which an expiration time is the expiration time of the token of
|
|
46
|
+
OAUTH2_CREDENTIALS = CREDENTIAL_SETS.fetch(4)
|
|
47
|
+
# The credentials of OAuth 2.0 that no other set holds, which a client that authenticates with OAuth 1.0a ignores
|
|
48
|
+
OAUTH2_ONLY_CREDENTIALS = %i[client_id client_secret refresh_token].freeze
|
|
49
|
+
private_constant :OAUTH1_CREDENTIALS, :OAUTH2_CREDENTIALS, :OAUTH2_ONLY_CREDENTIALS
|
|
50
|
+
|
|
51
|
+
# The message of the error raised for OAuth 2.0 credentials the client would leave unused
|
|
52
|
+
UNUSED_OAUTH2_CREDENTIALS = "%s are OAuth 2.0 credentials, which a client given OAuth 1.0a credentials would " \
|
|
53
|
+
"leave unused, since it authenticates with those, and the access_token they share is the OAuth 1.0a one. Pass " \
|
|
54
|
+
"the credentials of one or the other"
|
|
55
|
+
private_constant :UNUSED_OAUTH2_CREDENTIALS
|
|
56
|
+
|
|
57
|
+
# The message of the error raised for an expiration time the client would leave unused
|
|
58
|
+
UNUSED_EXPIRES_AT = "expires_at is the time an OAuth 2.0 access token expires, so it is given beside the " \
|
|
59
|
+
"client_id and access_token the client authenticates with, rather than beside OAuth 1.0a credentials, a " \
|
|
60
|
+
"bearer_token, an api_key and api_key_secret, or none, which would leave it unused. Leave it out"
|
|
61
|
+
private_constant :UNUSED_EXPIRES_AT
|
|
62
|
+
|
|
63
|
+
# The message of the error raised for an expiration time that is not a Time
|
|
64
|
+
INVALID_EXPIRES_AT = "expires_at must be a Time, such as Time.at(seconds) for a time stored as seconds since the " \
|
|
65
|
+
"epoch, or nil if it is not known"
|
|
66
|
+
private_constant :INVALID_EXPIRES_AT
|
|
67
|
+
|
|
68
|
+
# The message of the error raised for scopes the client would leave unused
|
|
69
|
+
UNUSED_SCOPES = "scopes are the scopes X granted an OAuth 2.0 access token, so they are given beside the " \
|
|
70
|
+
"client_id and access_token the client authenticates with, rather than beside OAuth 1.0a credentials, a " \
|
|
71
|
+
"bearer_token, an api_key and api_key_secret, or none, which would leave them unused. Leave them out"
|
|
72
|
+
private_constant :UNUSED_SCOPES
|
|
73
|
+
|
|
74
|
+
# The message of the error raised for scopes that are not an Array of scopes
|
|
75
|
+
INVALID_SCOPES = "scopes must be an Array of Strings that each name a scope, such as %w[tweet.read users.read], " \
|
|
76
|
+
"or nil if they are not known"
|
|
77
|
+
private_constant :INVALID_SCOPES
|
|
78
|
+
|
|
79
|
+
# A scope, which OAuth 2.0 names with printable characters other than a space, a quote, and a backslash
|
|
80
|
+
SCOPE = /\A[\x21\x23-\x5B\x5D-\x7E]+\z/
|
|
81
|
+
private_constant :SCOPE
|
|
82
|
+
|
|
83
|
+
# The message of the error raised for a credential that is an empty String
|
|
84
|
+
EMPTY_CREDENTIAL = "%s is empty. Pass the credential, or leave it out, since an empty one authenticates nothing"
|
|
85
|
+
private_constant :EMPTY_CREDENTIAL
|
|
86
|
+
|
|
87
|
+
# The message of the error raised for a credential that is not a String
|
|
88
|
+
NOT_A_STRING = "%s must be a String, not a %s"
|
|
89
|
+
private_constant :NOT_A_STRING
|
|
90
|
+
|
|
91
|
+
# The message raised for a credential an authenticator requires that is nil or empty
|
|
92
|
+
MISSING_CREDENTIAL = "%s is nil or empty. Pass the credential, which the authenticator cannot authenticate without"
|
|
93
|
+
private_constant :MISSING_CREDENTIAL
|
|
94
|
+
|
|
95
|
+
# The message of the error raised for an authenticator that is not one
|
|
96
|
+
NOT_AN_AUTHENTICATOR = "authenticator must be an X::Authenticator, such as an X::OAuth2Authenticator, or nil, " \
|
|
97
|
+
"not a %s"
|
|
98
|
+
private_constant :NOT_AN_AUTHENTICATOR
|
|
99
|
+
|
|
100
|
+
# The message of the error raised for an authenticator given beside credentials
|
|
101
|
+
AUTHENTICATOR_AND_CREDENTIALS = "An authenticator holds the credentials it authenticates with, so it cannot be " \
|
|
102
|
+
"given beside %s. Pass the authenticator, or the credentials, and leave out the other"
|
|
103
|
+
private_constant :AUTHENTICATOR_AND_CREDENTIALS
|
|
104
|
+
|
|
105
|
+
# Raise for a credential not a String or empty, or an expiration time not a Time
|
|
106
|
+
#
|
|
107
|
+
# An environment variable that is not set is often read as an empty String, as ENV.fetch("X_BEARER_TOKEN", "")
|
|
108
|
+
# reads it, which would send an Authorization header that authenticates nothing, for the API to refuse. A
|
|
109
|
+
# credential that is not a String, such as the Integer of a client ID read from YAML, would be signed with as the
|
|
110
|
+
# String it converts to, if it converts to one at all, so it is refused rather than sent as something else.
|
|
111
|
+
#
|
|
112
|
+
# @api private
|
|
113
|
+
# @param credentials [Hash{Symbol => String, Time, nil}] the credentials, as Client#initialize accepts them
|
|
114
|
+
# @return [void]
|
|
115
|
+
# @raise [ArgumentError] if a credential is neither a String nor nil, is an empty String, or one of whitespace
|
|
116
|
+
# alone, if the expiration time is neither a Time nor nil, or if the scopes are neither an Array of scopes nor
|
|
117
|
+
# nil
|
|
118
|
+
# @example Check the credentials of a client
|
|
119
|
+
# X::Core::CredentialValidator.validate_values!(bearer_token: "", expires_at: nil)
|
|
120
|
+
def validate_values!(credentials)
|
|
121
|
+
credentials.except(:expires_at, :scopes).each do |name, value|
|
|
122
|
+
next if value.nil?
|
|
123
|
+
raise ArgumentError, format(NOT_A_STRING, name, value.class) unless String === value
|
|
124
|
+
raise ArgumentError, format(EMPTY_CREDENTIAL, name) unless value.match?(/\S/)
|
|
125
|
+
end
|
|
126
|
+
validate_expires_at!(credentials[:expires_at])
|
|
127
|
+
validate_scopes!(credentials[:scopes])
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Raise for a credential an authenticator requires that is nil or empty
|
|
131
|
+
#
|
|
132
|
+
# An authenticator is given the credentials it authenticates with, so each is required, and one given as nil,
|
|
133
|
+
# as ENV[] reads a variable that is not set, or as an empty String, would authenticate nothing. They, and those it
|
|
134
|
+
# takes beside them, are checked as {#validate_values!} checks those of a client.
|
|
135
|
+
#
|
|
136
|
+
# @api private
|
|
137
|
+
# @param required [Hash{Symbol => String, nil}] the credentials the authenticator requires, by the names it
|
|
138
|
+
# takes them by
|
|
139
|
+
# @param others [Hash{Symbol => String, Time, nil}] the credentials and expiration time it takes beside them
|
|
140
|
+
# @return [void]
|
|
141
|
+
# @raise [ArgumentError] if a credential required is nil, an empty String, or one of whitespace alone
|
|
142
|
+
# @raise [ArgumentError] if a credential is neither a String nor nil, another credential is empty, or the
|
|
143
|
+
# expiration time is neither a Time nor nil
|
|
144
|
+
# @example Check the credential of a bearer token authenticator
|
|
145
|
+
# X::Core::CredentialValidator.validate_required!({bearer_token: ENV["X_BEARER_TOKEN"]})
|
|
146
|
+
def validate_required!(required, others = {})
|
|
147
|
+
required.each { |name, value| raise ArgumentError, format(MISSING_CREDENTIAL, name) unless value.to_s.match?(/\S/) }
|
|
148
|
+
validate_values!(required.merge(others))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Raise for an expiration time that is not a Time
|
|
152
|
+
#
|
|
153
|
+
# A token refresh compares the expiration time with the current time before every request, which fails for a time
|
|
154
|
+
# stored as a number or a String.
|
|
155
|
+
#
|
|
156
|
+
# @api private
|
|
157
|
+
# @param expires_at [Object] the expiration time
|
|
158
|
+
# @return [void]
|
|
159
|
+
# @raise [ArgumentError] if the expiration time is neither a Time nor nil
|
|
160
|
+
# @example Check an expiration time
|
|
161
|
+
# X::Core::CredentialValidator.validate_expires_at!(Time.now + 7200)
|
|
162
|
+
def validate_expires_at!(expires_at)
|
|
163
|
+
raise ArgumentError, INVALID_EXPIRES_AT unless expires_at.nil? || expires_at.is_a?(Time)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Raise for scopes that are not an Array of scopes
|
|
167
|
+
#
|
|
168
|
+
# @api private
|
|
169
|
+
# @param scopes [Object] the scopes
|
|
170
|
+
# @return [void]
|
|
171
|
+
# @raise [ArgumentError] if the scopes are neither an Array of Strings that each name a scope nor nil
|
|
172
|
+
# @example Check scopes
|
|
173
|
+
# X::Core::CredentialValidator.validate_scopes!(%w[tweet.read users.read])
|
|
174
|
+
def validate_scopes!(scopes)
|
|
175
|
+
raise ArgumentError, INVALID_SCOPES unless scopes.nil? || scopes?(scopes)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Check whether scopes are an Array of Strings that each name a scope
|
|
179
|
+
#
|
|
180
|
+
# @api private
|
|
181
|
+
# @param scopes [Object] the scopes
|
|
182
|
+
# @return [Boolean] true if the scopes are an Array of Strings that each name a scope
|
|
183
|
+
# @example Check scopes
|
|
184
|
+
# X::Core::CredentialValidator.scopes?(%w[tweet.read users.read]) # => true
|
|
185
|
+
def scopes?(scopes) = scopes.is_a?(Array) && scopes.all? { |scope| scope.is_a?(String) && SCOPE.match?(scope) }
|
|
186
|
+
|
|
187
|
+
# The scopes as the tokens, an authenticator, or a client holds them
|
|
188
|
+
#
|
|
189
|
+
# They are frozen, the Array and each String, apart from those given, so that the caller that gave them can
|
|
190
|
+
# change neither what the client holds nor what save_tokens is passed.
|
|
191
|
+
#
|
|
192
|
+
# @api private
|
|
193
|
+
# @param scopes [Array<String>, nil] the scopes, which validate_scopes! accepted
|
|
194
|
+
# @return [Array<String>, nil] the scopes, frozen, or nil for none
|
|
195
|
+
# @example Hold scopes
|
|
196
|
+
# X::Core::CredentialValidator.frozen_scopes(%w[tweet.read]) # => ["tweet.read"]
|
|
197
|
+
def frozen_scopes(scopes) = scopes&.map { |scope| -scope }.freeze
|
|
198
|
+
|
|
199
|
+
# Raise for an authenticator that is not one, or that is given beside credentials
|
|
200
|
+
#
|
|
201
|
+
# A client given an authenticator authenticates with it alone, so a credential given beside it, the expiration
|
|
202
|
+
# time of an OAuth 2.0 access token included, which an OAuth2Authenticator is built with, would go unused. The
|
|
203
|
+
# error names the class of something that is not an authenticator, rather than inspect it, since a Hash of
|
|
204
|
+
# credentials passed in its place would show them.
|
|
205
|
+
#
|
|
206
|
+
# @api private
|
|
207
|
+
# @param authenticator [Object] the authenticator, or nil for none
|
|
208
|
+
# @param credentials [Hash{Symbol => String, Time, nil}] the credentials, as Client#initialize accepts them
|
|
209
|
+
# @return [void]
|
|
210
|
+
# @raise [ArgumentError] if the authenticator is neither an Authenticator nor nil, or is given beside a
|
|
211
|
+
# credential
|
|
212
|
+
# @example Check the authenticator of a client
|
|
213
|
+
# X::Core::CredentialValidator.validate_authenticator!(authenticator, bearer_token: nil)
|
|
214
|
+
def validate_authenticator!(authenticator, credentials)
|
|
215
|
+
return if authenticator.nil?
|
|
216
|
+
raise ArgumentError, format(NOT_AN_AUTHENTICATOR, authenticator.class) unless authenticator.is_a?(Authenticator)
|
|
217
|
+
|
|
218
|
+
given = credentials.compact.keys
|
|
219
|
+
raise ArgumentError, format(AUTHENTICATOR_AND_CREDENTIALS, given.join(", ")) unless given.empty?
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Raise for incomplete credentials, or ones that leave others unused
|
|
223
|
+
#
|
|
224
|
+
# A client given OAuth 1.0a credentials authenticates with them, so OAuth 2.0 credentials given beside them, which
|
|
225
|
+
# would share their access token, raise. An expiration time and scopes are those of an OAuth 2.0 access token,
|
|
226
|
+
# which a client reads only when it authenticates with OAuth 2.0 credentials, so either given to a client that
|
|
227
|
+
# authenticates otherwise raises, as it does beside an authenticator.
|
|
228
|
+
#
|
|
229
|
+
# @api private
|
|
230
|
+
# @param credentials [Hash{Symbol => String, Time, Array<String>, nil}] the credentials, as Client#initialize
|
|
231
|
+
# accepts them
|
|
232
|
+
# @return [void]
|
|
233
|
+
# @raise [ArgumentError] if a credential belongs to no complete set, OAuth 2.0 credentials are given beside
|
|
234
|
+
# OAuth 1.0a ones, or an expiration time or scopes are given to a client that does not authenticate with OAuth
|
|
235
|
+
# 2.0 credentials
|
|
236
|
+
# @example Check the credentials of a client
|
|
237
|
+
# X::Core::CredentialValidator.validate!(api_key: "key")
|
|
238
|
+
def validate!(credentials)
|
|
239
|
+
raise ArgumentError, INCOMPLETE_CREDENTIALS if incomplete?(credentials)
|
|
240
|
+
|
|
241
|
+
unused = unused_oauth2_credentials(credentials)
|
|
242
|
+
raise ArgumentError, format(UNUSED_OAUTH2_CREDENTIALS, unused.join(", ")) unless unused.empty?
|
|
243
|
+
raise ArgumentError, UNUSED_EXPIRES_AT if unused?(credentials, :expires_at)
|
|
244
|
+
raise ArgumentError, UNUSED_SCOPES if unused?(credentials, :scopes)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
private
|
|
248
|
+
|
|
249
|
+
# Check whether a credential was given that belongs to no complete set
|
|
250
|
+
#
|
|
251
|
+
# The expiration time and scopes are no credentials, so they belong to no set, and unused? checks them.
|
|
252
|
+
#
|
|
253
|
+
# @api private
|
|
254
|
+
# @param credentials [Hash{Symbol => String, Time, Array<String>, nil}] the credentials
|
|
255
|
+
# @return [Boolean] true if the credentials do not form complete sets
|
|
256
|
+
def incomplete?(credentials)
|
|
257
|
+
given = credentials.except(:expires_at, :scopes).compact.keys
|
|
258
|
+
complete = CREDENTIAL_SETS.select { |set| (set - given).empty? }
|
|
259
|
+
(given - complete.flatten).any?
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# The OAuth 2.0 credentials given beside OAuth 1.0a ones, which would go unused
|
|
263
|
+
#
|
|
264
|
+
# @api private
|
|
265
|
+
# @param credentials [Hash{Symbol => String, Time, Array<String>, nil}] the credentials
|
|
266
|
+
# @return [Array<Symbol>] the names of the OAuth 2.0 credentials given, or none when OAuth 1.0a ones are not
|
|
267
|
+
def unused_oauth2_credentials(credentials)
|
|
268
|
+
given = credentials.compact.keys
|
|
269
|
+
(OAUTH1_CREDENTIALS - given).empty? ? OAUTH2_ONLY_CREDENTIALS & given : []
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Check whether expires_at or scopes were given that the client would leave unused
|
|
273
|
+
#
|
|
274
|
+
# @api private
|
|
275
|
+
# @param credentials [Hash{Symbol => String, Time, Array<String>, nil}] the credentials
|
|
276
|
+
# @param name [Symbol] the name of what was given of the access token, expires_at or scopes
|
|
277
|
+
# @return [Boolean] true if it was given, and the client holds no OAuth 2.0 credentials, which validate! refuses
|
|
278
|
+
# beside OAuth 1.0a ones before it checks this
|
|
279
|
+
def unused?(credentials, name)
|
|
280
|
+
given = credentials.compact.keys
|
|
281
|
+
given.include?(name) && !(OAUTH2_CREDENTIALS - given).empty?
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
private_constant :CredentialValidator
|
|
285
|
+
end
|
|
286
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# Error raised when the redirect back from X reports that the app was not authorized
|
|
7
|
+
#
|
|
8
|
+
# X redirects the user back to the app with an error, rather than a code, when the user declines to authorize the
|
|
9
|
+
# app, or when X cannot ask them, and the redirect is refused when its state does not match the one the app sent,
|
|
10
|
+
# or when it is not a valid URL. The redirect is a request of the user's browser, not a response of X, so the error
|
|
11
|
+
# holds no response, and descends from Error directly; a refusal of X to exchange the code raises
|
|
12
|
+
# AuthorizationError, a ClientError that holds the response of X.
|
|
13
|
+
#
|
|
14
|
+
# @api public
|
|
15
|
+
class AuthorizationDenied < Error
|
|
16
|
+
# The OAuth 2.0 error code the redirect reported, such as access_denied
|
|
17
|
+
# @api public
|
|
18
|
+
# @return [String, nil] the error code, or nil for a redirect that reported none, as one whose state does not
|
|
19
|
+
# match reports none
|
|
20
|
+
# @example Tell a user who declined from a failure
|
|
21
|
+
# rescue X::AuthorizationDenied => e
|
|
22
|
+
# redirect_to root_path if e.error_code.eql?("access_denied")
|
|
23
|
+
attr_reader :error_code
|
|
24
|
+
|
|
25
|
+
# Build the error of a redirect that simple_oauth refused
|
|
26
|
+
#
|
|
27
|
+
# Internal to x-core: it takes an error of simple_oauth, whose type may change within 1.x, and is private, so
|
|
28
|
+
# OAuth2Authorization calls it with __send__.
|
|
29
|
+
#
|
|
30
|
+
# @api private
|
|
31
|
+
# @param error [SimpleOAuth::OAuth2::Error] the failure
|
|
32
|
+
# @param default_message [String] the message when the redirect describes no reason
|
|
33
|
+
# @return [AuthorizationDenied] a new instance
|
|
34
|
+
# @example Raise the error of a redirect that reported a user who declined
|
|
35
|
+
# raise X::AuthorizationDenied.__send__(:from, error, "Authorization failed")
|
|
36
|
+
def self.from(error, default_message) = new(error.description || error.code || default_message, error_code: error.code)
|
|
37
|
+
private_class_method :from
|
|
38
|
+
|
|
39
|
+
# Initialize a new AuthorizationDenied
|
|
40
|
+
#
|
|
41
|
+
# @api public
|
|
42
|
+
# @param message [String, nil] the reason the app was not authorized, or nil for the name of the class, as an
|
|
43
|
+
# exception raised with no message is named
|
|
44
|
+
# @param error_code [String, nil] the OAuth 2.0 error code, or nil for none
|
|
45
|
+
# @return [AuthorizationDenied] a new instance
|
|
46
|
+
# @example Create an error
|
|
47
|
+
# X::AuthorizationDenied.new("The user denied the request", error_code: "access_denied")
|
|
48
|
+
# @example Raise the error with no message, as a test stub may
|
|
49
|
+
# raise X::AuthorizationDenied
|
|
50
|
+
def initialize(message = nil, error_code: nil)
|
|
51
|
+
super(message)
|
|
52
|
+
@error_code = error_code
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "client_error"
|
|
5
|
+
|
|
6
|
+
module X
|
|
7
|
+
# Error raised when X refuses to issue a token
|
|
8
|
+
#
|
|
9
|
+
# X refuses a token when it declines an authorization code, a refresh token, such as one that was revoked or already
|
|
10
|
+
# used, or an app's API key and secret. It answers with a response of 4xx, which the error holds, as any other
|
|
11
|
+
# ClientError holds the response that raised it, and the error code of the response tells the refusals apart. A
|
|
12
|
+
# token endpoint that fails to answer, with 429 Too Many Requests, a server error, a redirect, or a page that is not
|
|
13
|
+
# JSON, such as that of a proxy, firewall, or captive portal, refuses nothing, so it raises the HTTPError, such as
|
|
14
|
+
# the TooManyRequests or ServerError a client retries, or the InvalidResponse, a response of the API raises, rather
|
|
15
|
+
# than this error.
|
|
16
|
+
#
|
|
17
|
+
# It is a ClientError, so code that rescues the failures of a response, as rescue X::HTTPError does around the
|
|
18
|
+
# requests of a client, catches the refresh that X refuses in the middle of one. A client that refreshes an OAuth 2.0
|
|
19
|
+
# token the API rejected raises it when X refuses the refresh, with the Unauthorized that rejected the token as its
|
|
20
|
+
# cause, so rescue both to ask the user to authorize the app again: Unauthorized for credentials the API rejects,
|
|
21
|
+
# and this error for a refresh token X no longer accepts. The redirect back from X that reports a user who declined,
|
|
22
|
+
# or a state that does not match, carries no response of X, so it raises AuthorizationDenied instead.
|
|
23
|
+
#
|
|
24
|
+
# @api public
|
|
25
|
+
class AuthorizationError < ClientError
|
|
26
|
+
# The OAuth 2.0 error code X reported, such as invalid_grant or invalid_request
|
|
27
|
+
#
|
|
28
|
+
# It is read from the JSON of the body, whatever the content type of the response says, as OAuth 2.0 reads it.
|
|
29
|
+
#
|
|
30
|
+
# @api public
|
|
31
|
+
# @return [String, nil] the error code, or nil if X reported none
|
|
32
|
+
# @example Forget the tokens of a user whose refresh token X no longer accepts
|
|
33
|
+
# rescue X::AuthorizationError => e
|
|
34
|
+
# store.forget(user) if e.error_code.eql?("invalid_request")
|
|
35
|
+
def error_code
|
|
36
|
+
String.try_convert(Hash.try_convert(JSON.parse(body.to_s))&.[]("error"))
|
|
37
|
+
rescue JSON::ParserError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "client_error"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# Raised for a 400 Bad Request response, which the API sends for a request it cannot read, such as one whose
|
|
7
|
+
# parameters or body it does not take
|
|
8
|
+
# @api public
|
|
9
|
+
class BadRequest < ClientError; end
|
|
10
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X
|
|
4
|
+
module Core
|
|
5
|
+
# Stands in for an error a callback raised, so that what runs it does not take it for an error of its own
|
|
6
|
+
#
|
|
7
|
+
# A request runs the client's on_response hook, the block it was passed, and whatever an object_class builds its
|
|
8
|
+
# objects with, inside the handlers that send it again, wait out a rate limit, and refresh a rejected token, where
|
|
9
|
+
# an X::ServerError, an X::TooManyRequests, or an X::Unauthorized a callback raised, such as one of a request it
|
|
10
|
+
# made itself, would otherwise send the request again, reading what the API billed again, or spend a refresh
|
|
11
|
+
# token. The block of Client#get_stream runs inside the request that reads its body, where an X::Unauthorized it
|
|
12
|
+
# raised would otherwise refresh a token, and the stream be opened again.
|
|
13
|
+
#
|
|
14
|
+
# Internal to x-core: Client#perform, ResponseParser, and the stream Client#get_stream opens tag the errors of a
|
|
15
|
+
# callback with it, and Client raises the error it holds in its place, noted as a callback's, so that
|
|
16
|
+
# Client#with_retries, which runs outside the request, does not send it again for that error either.
|
|
17
|
+
#
|
|
18
|
+
# @api private
|
|
19
|
+
class CallbackError < StandardError
|
|
20
|
+
# The errors of callbacks raised in place of the CallbackError that tagged them, each held as its own key for as
|
|
21
|
+
# long as anything else holds it
|
|
22
|
+
UNTAGGED = ObjectSpace::WeakMap.new
|
|
23
|
+
private_constant :UNTAGGED
|
|
24
|
+
|
|
25
|
+
# The error the callback raised
|
|
26
|
+
# @api private
|
|
27
|
+
# @return [StandardError] the error the callback raised
|
|
28
|
+
# @example Raise the error a callback raised
|
|
29
|
+
# raise error.error
|
|
30
|
+
attr_reader :error
|
|
31
|
+
|
|
32
|
+
# Run a callback, tagging the error it raises
|
|
33
|
+
#
|
|
34
|
+
# An error already tagged, by a callback that runs within another, is raised as it is.
|
|
35
|
+
#
|
|
36
|
+
# @api private
|
|
37
|
+
# @yield [] runs the callback
|
|
38
|
+
# @return [Object] what the callback returned
|
|
39
|
+
# @raise [CallbackError] if the callback raised
|
|
40
|
+
# @example Run the hook of a response
|
|
41
|
+
# X::Core::CallbackError.tagging { on_response.call(summary) }
|
|
42
|
+
def self.tagging
|
|
43
|
+
yield
|
|
44
|
+
rescue CallbackError
|
|
45
|
+
raise
|
|
46
|
+
rescue => e
|
|
47
|
+
raise new(e)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The error a CallbackError holds, noted as a callback's, to raise in its place
|
|
51
|
+
#
|
|
52
|
+
# @api private
|
|
53
|
+
# @param tagged [CallbackError] the CallbackError that tagged the error
|
|
54
|
+
# @return [StandardError] the error the callback raised
|
|
55
|
+
# @example Raise the error a callback raised once the request is left
|
|
56
|
+
# raise X::Core::CallbackError.untag(e)
|
|
57
|
+
def self.untag(tagged)
|
|
58
|
+
UNTAGGED[tagged.error] = tagged.error
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Check whether an error is one a callback raised, which a request raised untagged
|
|
62
|
+
#
|
|
63
|
+
# A request raises it in place of the CallbackError that tagged it, once its handlers are left.
|
|
64
|
+
#
|
|
65
|
+
# @api private
|
|
66
|
+
# @param error [StandardError] the error a request raised
|
|
67
|
+
# @return [Boolean] true if a callback raised it
|
|
68
|
+
# @example Check whether an error is a callback's
|
|
69
|
+
# X::Core::CallbackError.untagged?(error) # => false
|
|
70
|
+
def self.untagged?(error) = UNTAGGED[error].equal?(error)
|
|
71
|
+
|
|
72
|
+
# Initialize a new CallbackError
|
|
73
|
+
#
|
|
74
|
+
# It takes the message of the error it stands in for, which is read only if it escapes what runs the callback.
|
|
75
|
+
#
|
|
76
|
+
# @api private
|
|
77
|
+
# @param error [StandardError] the error the callback raised
|
|
78
|
+
# @return [CallbackError] a new instance
|
|
79
|
+
# @example Tag the error a callback raised
|
|
80
|
+
# raise X::Core::CallbackError, error
|
|
81
|
+
def initialize(error)
|
|
82
|
+
super
|
|
83
|
+
@error = error
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
private_constant :CallbackError
|
|
87
|
+
end
|
|
88
|
+
end
|