x-core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +8 -0
  3. data/CHANGELOG.md +353 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +54 -0
  6. data/lib/x/core/app_only_authenticator.rb +198 -0
  7. data/lib/x/core/authenticator.rb +74 -0
  8. data/lib/x/core/authenticator_request.rb +56 -0
  9. data/lib/x/core/bearer_token_authenticator.rb +46 -0
  10. data/lib/x/core/built_response.rb +100 -0
  11. data/lib/x/core/client.rb +728 -0
  12. data/lib/x/core/client_app_only.rb +115 -0
  13. data/lib/x/core/client_credentials.rb +193 -0
  14. data/lib/x/core/client_internals.rb +315 -0
  15. data/lib/x/core/client_memo.rb +58 -0
  16. data/lib/x/core/client_settings.rb +166 -0
  17. data/lib/x/core/client_token_refresh.rb +281 -0
  18. data/lib/x/core/connection.rb +328 -0
  19. data/lib/x/core/connection_pool.rb +123 -0
  20. data/lib/x/core/connection_proxy.rb +151 -0
  21. data/lib/x/core/connection_request.rb +90 -0
  22. data/lib/x/core/credential_holder.rb +78 -0
  23. data/lib/x/core/credential_validator.rb +286 -0
  24. data/lib/x/core/errors/authorization_denied.rb +55 -0
  25. data/lib/x/core/errors/authorization_error.rb +41 -0
  26. data/lib/x/core/errors/bad_gateway.rb +9 -0
  27. data/lib/x/core/errors/bad_request.rb +10 -0
  28. data/lib/x/core/errors/callback_error.rb +88 -0
  29. data/lib/x/core/errors/client_error.rb +17 -0
  30. data/lib/x/core/errors/conflict.rb +9 -0
  31. data/lib/x/core/errors/error.rb +66 -0
  32. data/lib/x/core/errors/forbidden.rb +10 -0
  33. data/lib/x/core/errors/gateway_timeout.rb +9 -0
  34. data/lib/x/core/errors/gone.rb +9 -0
  35. data/lib/x/core/errors/http_error.rb +333 -0
  36. data/lib/x/core/errors/internal_server_error.rb +9 -0
  37. data/lib/x/core/errors/invalid_response.rb +96 -0
  38. data/lib/x/core/errors/method_not_allowed.rb +10 -0
  39. data/lib/x/core/errors/network_error.rb +59 -0
  40. data/lib/x/core/errors/not_acceptable.rb +10 -0
  41. data/lib/x/core/errors/not_found.rb +15 -0
  42. data/lib/x/core/errors/payload_too_large.rb +10 -0
  43. data/lib/x/core/errors/payment_required.rb +10 -0
  44. data/lib/x/core/errors/request_timeout.rb +9 -0
  45. data/lib/x/core/errors/server_error.rb +15 -0
  46. data/lib/x/core/errors/service_unavailable.rb +9 -0
  47. data/lib/x/core/errors/token_report_failed.rb +59 -0
  48. data/lib/x/core/errors/too_many_redirects.rb +53 -0
  49. data/lib/x/core/errors/too_many_requests.rb +80 -0
  50. data/lib/x/core/errors/unauthorized.rb +15 -0
  51. data/lib/x/core/errors/unavailable_for_legal_reasons.rb +10 -0
  52. data/lib/x/core/errors/unprocessable_entity.rb +10 -0
  53. data/lib/x/core/errors/unsupported_format.rb +22 -0
  54. data/lib/x/core/errors/unsupported_media_type.rb +10 -0
  55. data/lib/x/core/errors/unsupported_operation.rb +18 -0
  56. data/lib/x/core/oauth1_authenticator.rb +142 -0
  57. data/lib/x/core/oauth2_authenticator.rb +296 -0
  58. data/lib/x/core/oauth2_authorization.rb +405 -0
  59. data/lib/x/core/oauth2_refresh.rb +272 -0
  60. data/lib/x/core/oauth2_tokens.rb +277 -0
  61. data/lib/x/core/origin.rb +95 -0
  62. data/lib/x/core/problem.rb +302 -0
  63. data/lib/x/core/proxy_setting.rb +22 -0
  64. data/lib/x/core/rate_limit.rb +137 -0
  65. data/lib/x/core/rate_limit_handler.rb +166 -0
  66. data/lib/x/core/redirect_handler.rb +208 -0
  67. data/lib/x/core/refresh_reporter.rb +92 -0
  68. data/lib/x/core/request_builder.rb +155 -0
  69. data/lib/x/core/request_context.rb +85 -0
  70. data/lib/x/core/request_encoding.rb +135 -0
  71. data/lib/x/core/response.rb +196 -0
  72. data/lib/x/core/response_headers.rb +31 -0
  73. data/lib/x/core/response_parser.rb +155 -0
  74. data/lib/x/core/retry_handler.rb +149 -0
  75. data/lib/x/core/setting_validator.rb +399 -0
  76. data/lib/x/core/stream_body.rb +77 -0
  77. data/lib/x/core/stream_response.rb +267 -0
  78. data/lib/x/core/token_endpoint.rb +238 -0
  79. data/lib/x/core/version.rb +25 -0
  80. data/lib/x/core.rb +6 -0
  81. data/sig/manifest.yaml +8 -0
  82. data/sig/x-core.rbs +396 -0
  83. metadata +162 -0
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "http_error"
4
+
5
+ module X
6
+ # The base class of the errors of a 4xx response, which the API refused
7
+ #
8
+ # The request is most often the reason it was refused, so sending it again unchanged is refused again. Some are
9
+ # refused for a state that can change: TooManyRequests and RequestTimeout pass once the rate limit resets or the API
10
+ # reads the request in time, PaymentRequired once credit is added, and Conflict once a filtered stream has rules.
11
+ #
12
+ # @api public
13
+ # @example Tell a request the API refused from a failure of the API
14
+ # rescue X::ClientError => e
15
+ # logger.warn("#{e.status}: #{e.message}")
16
+ class ClientError < HTTPError; end
17
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "client_error"
4
+
5
+ module X
6
+ # Raised for a 409 Conflict response, which X sends a filtered stream that has no rules
7
+ # @api public
8
+ class Conflict < ClientError; end
9
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module X
4
+ # The base class of every error the X gems raise
5
+ #
6
+ # Rescuing it catches every way a request can fail: an HTTPError for a response that failed, a NetworkError for
7
+ # a request that never got a response, and the errors the gems raise for what they will not send or cannot read.
8
+ #
9
+ # X::Error
10
+ # ├── X::HTTPError a response that failed, which the error holds, and itself a redirect not followed
11
+ # │ ├── X::ClientError 4xx: the request was refused, and mostly is again
12
+ # │ │ ├── X::BadRequest 400
13
+ # │ │ ├── X::Unauthorized 401
14
+ # │ │ ├── X::PaymentRequired 402
15
+ # │ │ ├── X::Forbidden 403
16
+ # │ │ ├── X::NotFound 404
17
+ # │ │ ├── X::MethodNotAllowed 405
18
+ # │ │ ├── X::NotAcceptable 406
19
+ # │ │ ├── X::RequestTimeout 408
20
+ # │ │ ├── X::Conflict 409
21
+ # │ │ ├── X::Gone 410
22
+ # │ │ ├── X::PayloadTooLarge 413
23
+ # │ │ ├── X::UnsupportedMediaType 415
24
+ # │ │ ├── X::UnprocessableEntity 422
25
+ # │ │ ├── X::TooManyRequests 429
26
+ # │ │ ├── X::UnavailableForLegalReasons 451
27
+ # │ │ └── X::AuthorizationError X refused to issue a token, as its token endpoint answered
28
+ # │ ├── X::ServerError 5xx: the API failed, and the same request may pass later
29
+ # │ │ ├── X::InternalServerError 500
30
+ # │ │ ├── X::BadGateway 502
31
+ # │ │ ├── X::ServiceUnavailable 503
32
+ # │ │ └── X::GatewayTimeout 504
33
+ # │ └── X::InvalidResponse 2xx: a response that succeeded, whose body is not the JSON it claims
34
+ # ├── X::NetworkError the request never reached the API, or its response never arrived
35
+ # ├── X::AuthorizationDenied the redirect back from X says the app was not authorized
36
+ # ├── X::TokenReportFailed save_tokens raised for the tokens of an exchange of a code or a refresh
37
+ # ├── X::TooManyRedirects a response redirected more times than max_redirects allows
38
+ # ├── X::UnsupportedOperation the API, or the credentials of the client, offer no way to do what was asked
39
+ # ├── X::UnsupportedFormat Marshal, YAML, or JSON read a state written in a format this release does not read
40
+ # ├── X::Resources::Error the failures of the object layer, from x-resources
41
+ # │ ├── X::MissingResource a resource that was asked for does not exist
42
+ # │ ├── X::UnreadableResponse a response that succeeded says what the API does not document
43
+ # │ │ └── X::InvalidAttribute a response holds a value that is not what the API documents it to be
44
+ # │ └── X::MissingClient a resource that holds no client was asked to make a request
45
+ # ├── X::Streams::Error the failures of a stream, from x-streams
46
+ # │ ├── X::StreamError a line of a stream held errors and no data
47
+ # │ └── X::RulesRejected the API left rules of the filtered stream unchanged, and no block took them
48
+ # └── X::Uploads::Error the failures of an upload, from x-uploads
49
+ # ├── X::InvalidMedia the media does not exist, cannot be read, is empty, or is too large
50
+ # │ └── X::InvalidMediaType the media is of a type the API does not take
51
+ # ├── X::ChunkedUploadFailed a chunk or the finalize of an initialized upload failed
52
+ # ├── X::AltTextFailed the media was uploaded, but its alt text could not be added
53
+ # ├── X::MediaProcessingCheckFailed the media was uploaded, but a check of its processing failed
54
+ # ├── X::MediaProcessingFailed X could not process the media that was uploaded
55
+ # ├── X::MediaProcessingTimeout the media was still processing when the wait ran out
56
+ # └── X::MissingMediaData a response of an upload describes no media
57
+ #
58
+ # @api public
59
+ # @example Rescue every failure of a request
60
+ # begin
61
+ # client.get("users/me")
62
+ # rescue X::Error => e
63
+ # logger.error(e.message)
64
+ # end
65
+ class Error < StandardError; end
66
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "client_error"
4
+
5
+ module X
6
+ # Raised for a 403 Forbidden response, which the API sends for a request whose credentials it accepts but whose
7
+ # access level, or whose account, may not do what it asks
8
+ # @api public
9
+ class Forbidden < ClientError; end
10
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "server_error"
4
+
5
+ module X
6
+ # Raised for a 504 Gateway Timeout response, which the API sends when the service behind it took too long to answer
7
+ # @api public
8
+ class GatewayTimeout < ServerError; end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "client_error"
4
+
5
+ module X
6
+ # Raised for a 410 Gone response, which the API sends for an endpoint it has retired
7
+ # @api public
8
+ class Gone < ClientError; end
9
+ end
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "error"
6
+ require_relative "../built_response"
7
+ require_relative "../problem"
8
+ require_relative "../request_context"
9
+ require_relative "../response_headers"
10
+
11
+ module X
12
+ module Core
13
+ # Base class for HTTP errors from the X API
14
+ #
15
+ # The message is what the API said went wrong, read from the body of the response, behind the method and path of
16
+ # the request it answered. {#body} holds that body as it arrived, {#headers} the headers it came with, and
17
+ # {#problem} the problem it describes the failure with as a whole, and {#problems} each problem it names, for code
18
+ # that acts on the reason rather than logging it. {#http_method} and {#uri} are the request the API refused.
19
+ #
20
+ # A 4xx raises a ClientError, a 5xx a ServerError, and a body that is not JSON an InvalidResponse, each a subclass
21
+ # of this. It is raised itself for a redirect the client does not follow: a 300 Multiple Choices, 304 Not Modified,
22
+ # or 305 Use Proxy, and a redirect whose Location is missing, is not a valid URL, or is not an HTTP or HTTPS URL.
23
+ #
24
+ # @api public
25
+ class ::X::HTTPError < Error
26
+ include RequestContext
27
+ include ResponseHeaders
28
+
29
+ # @!method http_method
30
+ # The HTTP method the request was sent with
31
+ # @api public
32
+ # @return [Symbol, nil] the method, as :get, :post, :put, or :delete, or nil for an error built without one
33
+ # @example Tell a read that failed from a write
34
+ # writes_failed += 1 unless error.http_method.eql?(:get)
35
+ # @!method uri
36
+ # The URI the request was sent to
37
+ # @api public
38
+ # @return [URI::Generic, nil] the URI, or nil for an error built without one
39
+ # @example Count the failures of each endpoint
40
+ # failures[error.uri.path] += 1
41
+ # @!method headers
42
+ # The headers of the response
43
+ #
44
+ # The names are lowercase, and a field the API sent more than once is joined with a comma.
45
+ # @api public
46
+ # @return [Hash{String => String}] the headers, frozen
47
+ # @example Read how long the API took to answer
48
+ # error.headers["x-response-time"]
49
+
50
+ # Regular expression to match JSON content types
51
+ JSON_CONTENT_TYPE_REGEXP = %r{application/(problem\+|)json}
52
+ private_constant :JSON_CONTENT_TYPE_REGEXP
53
+ # The keys of a body that describes the failure itself, rather than naming the errors of the request
54
+ PROBLEM_KEYS = %w[title detail type error].freeze
55
+ private_constant :PROBLEM_KEYS
56
+ # The header that says how long to wait before sending the request again
57
+ RETRY_AFTER_HEADER = "retry-after"
58
+ private_constant :RETRY_AFTER_HEADER
59
+ # The value of a Retry-After header that counts seconds, rather than naming the time to wait until
60
+ RETRY_AFTER_SECONDS = /\A\d+\z/
61
+ private_constant :RETRY_AFTER_SECONDS
62
+ # The message of the error raised for an HTTPError itself, which has no status, given neither a response nor one
63
+ ANY_STATUS = "X::HTTPError is raised for a response of any status, so it is built with the status: of one, or the " \
64
+ "http_response: itself; raise the error of the status, such as X::NotFound, to build one without either"
65
+ private_constant :ANY_STATUS
66
+
67
+ # The response itself, as the client received it
68
+ #
69
+ # It is an escape hatch, for what the error does not read: the status is {#status}, the headers are
70
+ # {#headers}, and the body is {#body}. It is the response of the transport the client sent the request with, a
71
+ # Net::HTTPResponse today, or the one built of the status, headers, and body the error was given. Its class is
72
+ # not covered by the compatibility promise of 1.x: a later release of 1.x may send its requests with another
73
+ # library, whose response this then returns.
74
+ #
75
+ # @api public
76
+ # @return [Net::HTTPResponse] the response of the transport
77
+ # @example Read the reason phrase of the status line
78
+ # error.http_response.message # => "Too Many Requests"
79
+ attr_reader :http_response
80
+
81
+ # The problems the API named in the body of the response
82
+ #
83
+ # They are the errors the body names, such as each parameter of the request the API refused, or else the
84
+ # problem the body describes itself, which is {#problem}.
85
+ #
86
+ # @api public
87
+ # @return [Array<Problem>] the problems, frozen, empty for a response that describes none in JSON
88
+ # @example Name each parameter the API refused
89
+ # error.problems.flat_map { |problem| problem.to_h.fetch("parameters", {}).keys } # => ["ids", "user.fields"]
90
+ attr_reader :problems
91
+
92
+ # The problem the API described the failure with as a whole
93
+ #
94
+ # It is the problem the body of the response describes itself, by its title, detail, type, and status, whether
95
+ # or not the body names errors of its own, which are {#problems}, so its type reads the kind of failure alike
96
+ # for a request the API refused a parameter of and for one it refused to authorize; its attributes are the whole
97
+ # body. A body that describes no problem of its own, but names errors, as the responses of v1.1 do, is described
98
+ # by the first of them.
99
+ #
100
+ # @api public
101
+ # @return [Problem, nil] the problem, or nil for a response that describes none in JSON
102
+ # @example Tell a request the API found invalid from one it refused to authorize
103
+ # error.problem&.type # => "https://api.twitter.com/2/problems/invalid-request"
104
+ # @example Act on the reason rather than the status
105
+ # wait_for_the_next_month if error.problem&.usage_capped?
106
+ attr_reader :problem
107
+
108
+ # Initialize a new HTTPError
109
+ #
110
+ # Public, so that code that rescues an HTTPError, or a subclass such as NotFound, can be tested with one built from
111
+ # the status, headers, and body of a response, or from a Net::HTTP response, as x-core builds each from the
112
+ # response it parses. The error names the request, when given its method and URI, as x-core names the request the
113
+ # response answers.
114
+ #
115
+ # It can be raised as any other exception is, as in raise X::NotFound, or raise X::NotFound, "gone", for a test
116
+ # double that stands in for a client: an error of a status, such as NotFound, given neither a response nor a
117
+ # status, is built with the status x-core raises it for, a ClientError or ServerError with the first of its kind,
118
+ # 400 or 500, and an InvalidResponse with 200. An HTTPError itself, which is raised for a status of any kind, must
119
+ # be given one. A message given is the message of the error, in place of the one read from the body.
120
+ #
121
+ # @api public
122
+ # @param message [String, nil] the message, or nil for the one the body describes the failure with
123
+ # @param http_response [Net::HTTPResponse, nil] the response of the transport, an escape hatch as
124
+ # {#http_response} is, whose class is not covered by the compatibility promise of 1.x, or nil for one built of
125
+ # the status, headers, and body, which every release of 1.x takes
126
+ # @param status [Integer, nil] the status of the response, from 100 to 599, when it is not given, or nil for the
127
+ # status of the class
128
+ # @param headers [Hash{String => String}, nil] the headers of the response, when it is not given
129
+ # @param body [String, nil] the body of the response, when it is not given
130
+ # @param http_method [Symbol, String, nil] the method of the request the response answers, in any case
131
+ # @param uri [URI::Generic, nil] the URI of the request the response answers
132
+ # @return [HTTPError] a new instance
133
+ # @raise [ArgumentError] if the HTTP response is given beside a status, headers, or a body, or neither it nor a
134
+ # status is given to an HTTPError itself, or the status is not from 100 to 599, or the headers are not a Hash of
135
+ # names to values
136
+ # @example Create the error of a user that does not exist
137
+ # error = X::NotFound.new(status: 404, headers: {"content-type" => "application/json"},
138
+ # body: %({"title":"Not Found Error","detail":"Could not find user."}))
139
+ # @example Raise the error of a rate limit from a test double
140
+ # raise X::TooManyRequests, "Too Many Requests"
141
+ # @example Create an HTTP error from a response
142
+ # error = X::HTTPError.new(http_response: response, http_method: :get, uri: URI("https://api.x.com/2/users/me"))
143
+ def initialize(message = nil, http_response: nil, status: nil, headers: nil, body: nil, http_method: nil, uri: nil)
144
+ @http_response = built_response(http_response, status:, headers:, body:)
145
+ name_request(http_method, uri)
146
+ parsed = parsed_body
147
+ errors = Problem.all_from(parsed)
148
+ described = (Problem.new(parsed) if describes_problem?(parsed))
149
+ @problems = errors.empty? ? [described].compact.freeze : errors
150
+ @problem = described || errors.first
151
+ super(message_naming_request(message || message_from(parsed) || @http_response.message))
152
+ end
153
+
154
+ # The status an error of this class is built with by default
155
+ #
156
+ # It is the status an error given neither a response nor a status is built with: the status x-core raises the class, or the nearest class it descends from, for, or the first status of
157
+ # the kind of a ClientError or ServerError, 400 or 500.
158
+ #
159
+ # @api private
160
+ # @return [Integer, nil] the status, or nil for an HTTPError itself, which is raised for a status of any kind
161
+ # @example Get the status of a NotFound
162
+ # X::NotFound.__send__(:default_status) # => 404
163
+ def self.default_status
164
+ parser = Core.const_get(:ResponseParser)
165
+ ancestors.each do |ancestor|
166
+ status = parser::ERROR_MAP.key(ancestor) || parser::STATUS_CLASS_ERRORS.key(ancestor)&.*(100)
167
+ return status if status
168
+ end
169
+ nil
170
+ end
171
+ private_class_method :default_status
172
+
173
+ # The HTTP status code, as an Integer like X::Response#status
174
+ #
175
+ # @api public
176
+ # @return [Integer] the HTTP status code
177
+ # @example Handle a status the errors do not name
178
+ # retry if error.status.eql?(408)
179
+ def status = Integer(http_response.code)
180
+
181
+ # The body of the response, as it arrived
182
+ #
183
+ # A server can send any body with an error, so it is the JSON the API describes a failure with, or whatever
184
+ # else was sent in its place, such as the page of a proxy. It is tagged UTF-8, the encoding of the JSON the API
185
+ # sends, and a body that is not valid UTF-8 keeps its bytes, so valid_encoding? tells it apart.
186
+ #
187
+ # @api public
188
+ # @return [String, nil] the body, tagged UTF-8, or nil for a response without one
189
+ # @example Log what the API sent
190
+ # logger.error(error.body)
191
+ def body = http_response.body
192
+
193
+ # The seconds the response asks a request to wait before it is sent again
194
+ #
195
+ # The API sends a Retry-After header with a request it refused for a rate limit, and with some of the responses
196
+ # of a failure of its own, such as a 503 that names the time its endpoint is expected back. The header counts
197
+ # the seconds from when the response was sent, or names the time to wait until. A client waits it out before it
198
+ # sends an idempotent request refused for a failure of the API's own again, up to a minute, and before it sends
199
+ # any request refused for a rate limit again, up to max_rate_limit_wait; see {Client#initialize}.
200
+ #
201
+ # @api public
202
+ # @return [Integer, nil] the seconds, never negative, or nil for a response that does not say
203
+ # @example Wait as long as the API asks before sending a request again
204
+ # sleep(error.retry_after || 1)
205
+ def retry_after
206
+ value = http_response[RETRY_AFTER_HEADER]
207
+ return if value.nil?
208
+
209
+ value.match?(RETRY_AFTER_SECONDS) ? Integer(value, 10) : seconds_until(value)
210
+ end
211
+
212
+ private
213
+
214
+ # The HTTP response given, or the one built of the status, headers, and body
215
+ #
216
+ # A response given neither a response nor a status is built with the status of the class, and an HTTPError itself,
217
+ # which has none, raises.
218
+ #
219
+ # @api private
220
+ # @param http_response [Net::HTTPResponse, nil] the HTTP response, or nil to build one
221
+ # @param status [Integer, nil] the status of the response to build, or nil for the status of the class
222
+ # @param headers [Hash{String => String}, nil] the headers of the response to build, or nil for none
223
+ # @param body [String, nil] the body of the response to build, or nil for none
224
+ # @return [Net::HTTPResponse] the HTTP response
225
+ # @raise [ArgumentError] if the error is given neither a response nor a status, and its class has no status, or as
226
+ # {BuiltResponse.of} raises
227
+ def built_response(http_response, status:, headers:, body:)
228
+ status ||= self.class.__send__(:default_status) if http_response.nil?
229
+ raise ArgumentError, ANY_STATUS if http_response.nil? && status.nil?
230
+
231
+ BuiltResponse.of(http_response, status:, headers:, body:)
232
+ end
233
+
234
+ # The seconds until the time an HTTP date names
235
+ #
236
+ # They count from the Date the response was sent at, on the clock of the API that named the time, so the wait
237
+ # holds however far the local clock is from the API's; a response that names no Date counts from now.
238
+ #
239
+ # @api private
240
+ # @param value [String] the value of the header
241
+ # @return [Integer, nil] the seconds, never negative, or nil if the value names no time
242
+ def seconds_until(value)
243
+ [(Time.httpdate(value) - sent_at).ceil, 0].max
244
+ rescue ArgumentError
245
+ nil
246
+ end
247
+
248
+ # The time the response was sent at, by its Date header
249
+ #
250
+ # A response that names no Date, or one that cannot be read, was sent now.
251
+ #
252
+ # @api private
253
+ # @return [Time] the time
254
+ def sent_at
255
+ date = http_response["date"]
256
+ date.nil? ? Time.now : Time.httpdate(date)
257
+ rescue ArgumentError
258
+ Time.now
259
+ end
260
+
261
+ # The body of the response, parsed as a JSON object
262
+ #
263
+ # A server can send any body with an error, so a body that is not JSON, or that is not the JSON its content
264
+ # type claims, parses to an empty object rather than raise while the error is built.
265
+ #
266
+ # @api private
267
+ # @return [Hash{String => Object}] the parsed body, empty for a body that is not a JSON object
268
+ def parsed_body
269
+ return {} unless json?
270
+
271
+ Hash.try_convert(JSON.parse(body.to_s)) || {}
272
+ rescue JSON::ParserError
273
+ {}
274
+ end
275
+
276
+ # Check whether a body describes a problem itself, rather than only naming errors
277
+ #
278
+ # @api private
279
+ # @param body [Hash{String => Object}] the parsed body
280
+ # @return [Boolean] true if the body holds a title, detail, type, or error of its own
281
+ def describes_problem?(body) = PROBLEM_KEYS.any? { |key| body.key?(key) }
282
+
283
+ # The message a body describes the failure with
284
+ #
285
+ # A body that holds no message leaves the error with the status message of the response.
286
+ #
287
+ # @api private
288
+ # @param body [Hash{String => Object}] the parsed body
289
+ # @return [String, nil] the message, or nil if the body holds none
290
+ def message_from(body)
291
+ message_from_errors(body["errors"]) || message_from_problem(body) || String.try_convert(body["error"])
292
+ end
293
+
294
+ # Join the messages of an errors array
295
+ #
296
+ # Each error gives its message, or else its detail or title.
297
+ #
298
+ # @api private
299
+ # @param errors [Object] the errors of the body
300
+ # @return [String, nil] the joined messages, or nil if there are none
301
+ def message_from_errors(errors)
302
+ messages = Array(errors).filter_map do |error|
303
+ Hash.try_convert(error)&.values_at("message", "detail", "title")&.grep(String)&.first
304
+ end
305
+ messages.join(", ") unless messages.empty?
306
+ end
307
+
308
+ # The title and detail of a problem, joined
309
+ #
310
+ # A detail that says no more than the title, as the Unauthorized of a 401 does, is left out, rather than
311
+ # repeated behind it.
312
+ #
313
+ # @api private
314
+ # @param body [Hash{String => untyped}] the body
315
+ # @return [String, nil] the title and detail, the title alone if the detail is the same, or nil unless the body
316
+ # has both
317
+ def message_from_problem(body)
318
+ title = body["title"]
319
+ detail = body["detail"]
320
+ return unless title && detail
321
+
322
+ title.eql?(detail) ? title : "#{title}: #{detail}"
323
+ end
324
+
325
+ # Check whether the response carries JSON
326
+ # @api private
327
+ # @return [Boolean] true if the response is JSON
328
+ def json?
329
+ JSON_CONTENT_TYPE_REGEXP === http_response["content-type"]
330
+ end
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "server_error"
4
+
5
+ module X
6
+ # Raised for a 500 Internal Server Error response, which the API sends when a request it accepted failed
7
+ # @api public
8
+ class InternalServerError < ServerError; end
9
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "http_error"
4
+
5
+ module X
6
+ module Core
7
+ # Error raised for a successful response whose body is not JSON, such as the page of a proxy or captive portal
8
+ #
9
+ # It is an X::HTTPError, so that code that rescues the failures of a response reads it as it reads any other:
10
+ # the status is {#status}, the headers are {#headers}, the body is {#body}, and {#http_method} and {#uri} are the
11
+ # request it answered. A body that is not JSON describes no problem, so {#problem} is nil and {#problems} empty.
12
+ #
13
+ # @api public
14
+ class ::X::InvalidResponse < ::X::HTTPError
15
+ # The body that is not JSON: the whole body of a response, or the line of a stream
16
+ #
17
+ # The body of a stream can be read only as it arrives, so an error raised for a line of a stream holds that line.
18
+ # It is tagged UTF-8, as {Response#body} is, and keeps the bytes of a body that is not valid UTF-8.
19
+ #
20
+ # An error given no body holds the body of its response, as {HTTPError#body} does, once that response has been
21
+ # read whole. The body of a response that has not been read, as that of a stream still arriving, is never read
22
+ # for it, since reading it would wait for the rest of the stream, or take the lines the stream has yet to read.
23
+ #
24
+ # @api public
25
+ # @return [String, nil] the body, or the line of a stream, tagged UTF-8, or else the body of the response once it
26
+ # has been read whole, or nil for an error of a response that has not been, or that has none
27
+ # @example Read the body that could not be parsed
28
+ # error.body
29
+ def body = @body || body_read
30
+
31
+ # Initialize a new InvalidResponse
32
+ #
33
+ # Public, so that code that rescues an InvalidResponse can be tested with one built from the status, headers,
34
+ # and body of a response, or from a Net::HTTP response, as ResponseParser and the stream of x-streams build one. The error
35
+ # names the request, when given its method and URI, as x-core names the request the response answers.
36
+ #
37
+ # It can be raised as any other exception is, as in raise X::InvalidResponse, and is built with a status of 200
38
+ # when it is given neither a response nor a status, as HTTPError states.
39
+ #
40
+ # @api public
41
+ # @param message [String, nil] the message, or nil for the one that names the status and content type
42
+ # @param http_response [Net::HTTPResponse, nil] the response of the transport, an escape hatch as
43
+ # {#http_response} is, whose class is not covered by the compatibility promise of 1.x, or nil for one built of
44
+ # the status, headers, and body, which every release of 1.x takes
45
+ # @param status [Integer, nil] the status of the response, from 100 to 599, when it is not given, or nil for 200
46
+ # @param headers [Hash{String => String}, nil] the headers of the response, when it is not given
47
+ # @param body [String, nil] the body that is not JSON, which is the body of a response built of the status
48
+ # @param http_method [Symbol, String, nil] the method of the request the response answers, in any case
49
+ # @param uri [URI::Generic, nil] the URI of the request the response answers
50
+ # @return [InvalidResponse] a new instance
51
+ # @raise [ArgumentError] if the HTTP response is given beside a status or headers, or the status is not from 100
52
+ # to 599, or the headers are not a Hash of names to values
53
+ # @example Create the error of a page a proxy answered with
54
+ # error = X::InvalidResponse.new(status: 200, headers: {"content-type" => "text/html"}, body: "<html></html>")
55
+ # @example Create an error for a line of a stream
56
+ # error = X::InvalidResponse.new(http_response: response, body: line, http_method: :get, uri: stream_uri)
57
+ def initialize(message = nil, http_response: nil, status: nil, headers: nil, body: nil, http_method: nil, uri: nil)
58
+ @body = body.dup&.force_encoding(Encoding::UTF_8)
59
+ super(message, http_response:, status:, headers:, body: (body if http_response.nil?), http_method:, uri:)
60
+ end
61
+
62
+ # The status an InvalidResponse is built with by default
63
+ #
64
+ # It is the status one given neither a response nor a status is built with. A body that is not JSON is raised for a response that succeeded, so it is 200 OK.
65
+ #
66
+ # @api private
67
+ # @return [Integer] the status, 200
68
+ # @example Get the status of an InvalidResponse
69
+ # X::InvalidResponse.__send__(:default_status) # => 200
70
+ def self.default_status = 200
71
+ private_class_method :default_status
72
+
73
+ private
74
+
75
+ # The body of the response, if it has been read whole
76
+ #
77
+ # A response read whole holds its body as a String, where one read in chunks, as a stream is, holds what it read
78
+ # them with, and one not yet read reads its body when asked for it, which this never asks it.
79
+ #
80
+ # @api private
81
+ # @return [String, nil] the body, or nil for a response not yet read, read in chunks, or without one
82
+ def body_read
83
+ String.try_convert(http_response.body) if http_response.instance_variable_get(:@read)
84
+ end
85
+
86
+ # The message of the error, which says the body is not JSON
87
+ #
88
+ # It names what the response says the body is instead, since a body that is not JSON holds no message.
89
+ #
90
+ # @api private
91
+ # @param _body [Hash{String => Object}] the parsed body, which is empty
92
+ # @return [String] the message
93
+ def message_from(_body) = "The body of the #{http_response.code} response is not JSON (#{http_response["content-type"] || "no content type"})"
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "client_error"
4
+
5
+ module X
6
+ # Raised for a 405 Method Not Allowed response, which the API sends for a method the endpoint does not take, such
7
+ # as a POST to an endpoint that only reads
8
+ # @api public
9
+ class MethodNotAllowed < ClientError; end
10
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+ require_relative "../request_context"
5
+
6
+ module X
7
+ module Core
8
+ # Raised when a request never reached the API, or its response never arrived
9
+ #
10
+ # It stands for every error a socket raises: a refused, reset, or dropped connection, a host that cannot be
11
+ # resolved, a TLS handshake that failed, a proxy that refused to open a tunnel, and the open, read, and write
12
+ # timeouts of the client. A client sends an idempotent request again only after one that never reached the API,
13
+ # such as a refused connection or a timeout opening it, and leaves the rest, and any POST, to the caller, since
14
+ # whether the API acted on it is unknown; see Client#initialize.
15
+ #
16
+ # The message names the request that failed, and {#http_method} and {#uri} read it, since nothing of a request
17
+ # that got no response is left to read it from.
18
+ #
19
+ # @api public
20
+ # @example Tell a request that failed on the network from one the API refused
21
+ # rescue X::NetworkError => e
22
+ # logger.warn("#{e.message}, giving up")
23
+ class ::X::NetworkError < Error
24
+ include RequestContext
25
+
26
+ # @!method http_method
27
+ # The HTTP method the request that failed was sent with
28
+ # @api public
29
+ # @return [Symbol, nil] the method, as :get, :post, :put, or :delete, or nil for an error built without one
30
+ # @example Tell a read that failed from a write
31
+ # writes_failed += 1 unless error.http_method.eql?(:get)
32
+ # @!method uri
33
+ # The URI the request that failed was sent to
34
+ # @api public
35
+ # @return [URI::Generic, nil] the URI, or nil for an error built without one
36
+ # @example Count the failures of each host
37
+ # failures[error.uri.host] += 1
38
+
39
+ # Initialize a new NetworkError
40
+ #
41
+ # Public, so that code that rescues a NetworkError can be tested with one built by hand, as Connection builds one
42
+ # for the errors a socket raises, or raised with no message, as any exception is. The error names the request,
43
+ # when given its method and URI, as x-core names the request that failed.
44
+ #
45
+ # @api public
46
+ # @param message [String, nil] what went wrong on the network, or nil for the name of the class, as an exception
47
+ # raised with no message is named, which names no request
48
+ # @param http_method [Symbol, String, nil] the method of the request that failed, in any case
49
+ # @param uri [URI::Generic, nil] the URI of the request that failed
50
+ # @return [NetworkError] a new instance
51
+ # @example Create a network error
52
+ # error = X::NetworkError.new("Network error: Connection refused", http_method: :get, uri: URI("https://api.x.com/2/users/me"))
53
+ def initialize(message = nil, http_method: nil, uri: nil)
54
+ name_request(http_method, uri)
55
+ super(message && message_naming_request(message))
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "client_error"
4
+
5
+ module X
6
+ # Raised for a 406 Not Acceptable response, which the API sends for a request that asks for a format the endpoint
7
+ # does not serve
8
+ # @api public
9
+ class NotAcceptable < ClientError; end
10
+ end