http 5.3.1 → 6.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 +4 -4
- data/CHANGELOG.md +241 -41
- data/LICENSE.txt +1 -1
- data/README.md +110 -13
- data/UPGRADING.md +491 -0
- data/http.gemspec +32 -29
- data/lib/http/base64.rb +11 -1
- data/lib/http/chainable/helpers.rb +62 -0
- data/lib/http/chainable/verbs.rb +136 -0
- data/lib/http/chainable.rb +232 -136
- data/lib/http/client.rb +158 -127
- data/lib/http/connection/internals.rb +141 -0
- data/lib/http/connection.rb +126 -97
- data/lib/http/content_type.rb +61 -6
- data/lib/http/errors.rb +25 -1
- data/lib/http/feature.rb +65 -5
- data/lib/http/features/auto_deflate.rb +124 -17
- data/lib/http/features/auto_inflate.rb +38 -15
- data/lib/http/features/caching/entry.rb +178 -0
- data/lib/http/features/caching/in_memory_store.rb +63 -0
- data/lib/http/features/caching.rb +216 -0
- data/lib/http/features/digest_auth.rb +234 -0
- data/lib/http/features/instrumentation.rb +97 -17
- data/lib/http/features/logging.rb +183 -5
- data/lib/http/features/normalize_uri.rb +17 -0
- data/lib/http/features/raise_error.rb +18 -3
- data/lib/http/form_data/composite_io.rb +106 -0
- data/lib/http/form_data/file.rb +95 -0
- data/lib/http/form_data/multipart/param.rb +62 -0
- data/lib/http/form_data/multipart.rb +106 -0
- data/lib/http/form_data/part.rb +52 -0
- data/lib/http/form_data/readable.rb +58 -0
- data/lib/http/form_data/urlencoded.rb +175 -0
- data/lib/http/form_data/version.rb +8 -0
- data/lib/http/form_data.rb +102 -0
- data/lib/http/headers/known.rb +3 -0
- data/lib/http/headers/normalizer.rb +17 -36
- data/lib/http/headers.rb +172 -65
- data/lib/http/mime_type/adapter.rb +24 -9
- data/lib/http/mime_type/json.rb +19 -4
- data/lib/http/mime_type.rb +21 -3
- data/lib/http/options/definitions.rb +189 -0
- data/lib/http/options.rb +172 -125
- data/lib/http/redirector.rb +80 -75
- data/lib/http/request/body.rb +87 -6
- data/lib/http/request/builder.rb +184 -0
- data/lib/http/request/proxy.rb +83 -0
- data/lib/http/request/writer.rb +76 -16
- data/lib/http/request.rb +214 -98
- data/lib/http/response/body.rb +103 -18
- data/lib/http/response/inflater.rb +35 -7
- data/lib/http/response/parser.rb +98 -4
- data/lib/http/response/status/reasons.rb +2 -4
- data/lib/http/response/status.rb +141 -31
- data/lib/http/response.rb +219 -61
- data/lib/http/retriable/delay_calculator.rb +38 -11
- data/lib/http/retriable/errors.rb +21 -0
- data/lib/http/retriable/performer.rb +82 -38
- data/lib/http/session.rb +280 -0
- data/lib/http/timeout/global.rb +147 -34
- data/lib/http/timeout/null.rb +155 -9
- data/lib/http/timeout/per_operation.rb +139 -18
- data/lib/http/uri/normalizer.rb +82 -0
- data/lib/http/uri/parsing.rb +182 -0
- data/lib/http/uri.rb +289 -124
- data/lib/http/version.rb +2 -1
- data/lib/http.rb +11 -2
- data/sig/deps.rbs +122 -0
- data/sig/http.rbs +1619 -0
- data/test/http/base64_test.rb +28 -0
- data/test/http/client_test.rb +739 -0
- data/test/http/connection_test.rb +1533 -0
- data/test/http/content_type_test.rb +190 -0
- data/test/http/errors_test.rb +28 -0
- data/test/http/feature_test.rb +49 -0
- data/test/http/features/auto_deflate_test.rb +317 -0
- data/test/http/features/auto_inflate_test.rb +213 -0
- data/test/http/features/caching_test.rb +942 -0
- data/test/http/features/digest_auth_test.rb +996 -0
- data/test/http/features/instrumentation_test.rb +246 -0
- data/test/http/features/logging_test.rb +654 -0
- data/test/http/features/normalize_uri_test.rb +41 -0
- data/test/http/features/raise_error_test.rb +77 -0
- data/test/http/form_data/composite_io_test.rb +215 -0
- data/test/http/form_data/file_test.rb +255 -0
- data/test/http/form_data/fixtures/the-http-gem.info +1 -0
- data/test/http/form_data/multipart_test.rb +303 -0
- data/test/http/form_data/part_test.rb +90 -0
- data/test/http/form_data/urlencoded_test.rb +164 -0
- data/test/http/form_data_test.rb +232 -0
- data/test/http/headers/normalizer_test.rb +93 -0
- data/test/http/headers_test.rb +888 -0
- data/test/http/mime_type/json_test.rb +39 -0
- data/test/http/mime_type_test.rb +150 -0
- data/test/http/options/base_uri_test.rb +148 -0
- data/test/http/options/body_test.rb +21 -0
- data/test/http/options/features_test.rb +38 -0
- data/test/http/options/form_test.rb +21 -0
- data/test/http/options/headers_test.rb +32 -0
- data/test/http/options/json_test.rb +21 -0
- data/test/http/options/merge_test.rb +78 -0
- data/test/http/options/new_test.rb +37 -0
- data/test/http/options/proxy_test.rb +32 -0
- data/test/http/options_test.rb +575 -0
- data/test/http/redirector_test.rb +639 -0
- data/test/http/request/body_test.rb +318 -0
- data/test/http/request/builder_test.rb +623 -0
- data/test/http/request/writer_test.rb +391 -0
- data/test/http/request_test.rb +1733 -0
- data/test/http/response/body_test.rb +292 -0
- data/test/http/response/parser_test.rb +105 -0
- data/test/http/response/status_test.rb +322 -0
- data/test/http/response_test.rb +502 -0
- data/test/http/retriable/delay_calculator_test.rb +194 -0
- data/test/http/retriable/errors_test.rb +71 -0
- data/test/http/retriable/performer_test.rb +551 -0
- data/test/http/session_test.rb +424 -0
- data/test/http/timeout/global_test.rb +239 -0
- data/test/http/timeout/null_test.rb +218 -0
- data/test/http/timeout/per_operation_test.rb +220 -0
- data/test/http/uri/normalizer_test.rb +89 -0
- data/test/http/uri_test.rb +1140 -0
- data/test/http/version_test.rb +15 -0
- data/test/http_test.rb +818 -0
- data/test/regression_tests.rb +27 -0
- data/test/support/dummy_server/encoding_routes.rb +47 -0
- data/test/support/dummy_server/routes.rb +201 -0
- data/test/support/dummy_server/servlet.rb +81 -0
- data/test/support/dummy_server.rb +200 -0
- data/{spec → test}/support/fakeio.rb +2 -2
- data/test/support/http_handling_shared/connection_reuse_tests.rb +97 -0
- data/test/support/http_handling_shared/timeout_tests.rb +134 -0
- data/test/support/http_handling_shared.rb +11 -0
- data/test/support/proxy_server.rb +207 -0
- data/test/support/servers/runner.rb +67 -0
- data/{spec → test}/support/simplecov.rb +11 -2
- data/test/support/ssl_helper.rb +108 -0
- data/test/test_helper.rb +38 -0
- metadata +108 -168
- data/.github/workflows/ci.yml +0 -67
- data/.gitignore +0 -15
- data/.rspec +0 -1
- data/.rubocop/layout.yml +0 -8
- data/.rubocop/metrics.yml +0 -4
- data/.rubocop/rspec.yml +0 -9
- data/.rubocop/style.yml +0 -32
- data/.rubocop.yml +0 -11
- data/.rubocop_todo.yml +0 -219
- data/.yardopts +0 -2
- data/CHANGES_OLD.md +0 -1002
- data/Gemfile +0 -51
- data/Guardfile +0 -18
- data/Rakefile +0 -64
- data/lib/http/headers/mixin.rb +0 -34
- data/lib/http/retriable/client.rb +0 -37
- data/logo.png +0 -0
- data/spec/lib/http/client_spec.rb +0 -556
- data/spec/lib/http/connection_spec.rb +0 -88
- data/spec/lib/http/content_type_spec.rb +0 -47
- data/spec/lib/http/features/auto_deflate_spec.rb +0 -77
- data/spec/lib/http/features/auto_inflate_spec.rb +0 -86
- data/spec/lib/http/features/instrumentation_spec.rb +0 -81
- data/spec/lib/http/features/logging_spec.rb +0 -65
- data/spec/lib/http/features/raise_error_spec.rb +0 -62
- data/spec/lib/http/headers/mixin_spec.rb +0 -36
- data/spec/lib/http/headers/normalizer_spec.rb +0 -52
- data/spec/lib/http/headers_spec.rb +0 -527
- data/spec/lib/http/options/body_spec.rb +0 -15
- data/spec/lib/http/options/features_spec.rb +0 -33
- data/spec/lib/http/options/form_spec.rb +0 -15
- data/spec/lib/http/options/headers_spec.rb +0 -24
- data/spec/lib/http/options/json_spec.rb +0 -15
- data/spec/lib/http/options/merge_spec.rb +0 -68
- data/spec/lib/http/options/new_spec.rb +0 -30
- data/spec/lib/http/options/proxy_spec.rb +0 -20
- data/spec/lib/http/options_spec.rb +0 -13
- data/spec/lib/http/redirector_spec.rb +0 -530
- data/spec/lib/http/request/body_spec.rb +0 -211
- data/spec/lib/http/request/writer_spec.rb +0 -121
- data/spec/lib/http/request_spec.rb +0 -234
- data/spec/lib/http/response/body_spec.rb +0 -85
- data/spec/lib/http/response/parser_spec.rb +0 -74
- data/spec/lib/http/response/status_spec.rb +0 -253
- data/spec/lib/http/response_spec.rb +0 -262
- data/spec/lib/http/retriable/delay_calculator_spec.rb +0 -69
- data/spec/lib/http/retriable/performer_spec.rb +0 -302
- data/spec/lib/http/uri/normalizer_spec.rb +0 -95
- data/spec/lib/http/uri_spec.rb +0 -71
- data/spec/lib/http_spec.rb +0 -535
- data/spec/regression_specs.rb +0 -24
- data/spec/spec_helper.rb +0 -89
- data/spec/support/black_hole.rb +0 -13
- data/spec/support/dummy_server/servlet.rb +0 -203
- data/spec/support/dummy_server.rb +0 -44
- data/spec/support/fuubar.rb +0 -21
- data/spec/support/http_handling_shared.rb +0 -190
- data/spec/support/proxy_server.rb +0 -39
- data/spec/support/servers/config.rb +0 -11
- data/spec/support/servers/runner.rb +0 -19
- data/spec/support/ssl_helper.rb +0 -104
- /data/{spec → test}/support/capture_warning.rb +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module HTTP
|
|
4
|
+
# Namespace for HTTP client features
|
|
4
5
|
module Features
|
|
5
6
|
# Log requests and responses. Request verb and uri, and Response status are
|
|
6
7
|
# logged at `info`, and the headers and bodies of both are logged at
|
|
@@ -8,9 +9,21 @@ module HTTP
|
|
|
8
9
|
#
|
|
9
10
|
# HTTP.use(logging: {logger: Logger.new(STDOUT)}).get("https://example.com/")
|
|
10
11
|
#
|
|
12
|
+
# Binary bodies (IO/Enumerable request sources and binary-encoded
|
|
13
|
+
# responses) are formatted using the +binary_formatter+ option instead
|
|
14
|
+
# of being dumped raw. Available formatters:
|
|
15
|
+
#
|
|
16
|
+
# - +:stats+ (default) — logs <tt>BINARY DATA (N bytes)</tt>
|
|
17
|
+
# - +:base64+ — logs <tt>BINARY DATA (N bytes)\n<base64></tt>
|
|
18
|
+
# - +Proc+ — calls the proc with the raw binary string
|
|
19
|
+
#
|
|
20
|
+
# @example Custom binary formatter
|
|
21
|
+
# HTTP.use(logging: {logger: Logger.new(STDOUT), binary_formatter: :base64})
|
|
22
|
+
#
|
|
11
23
|
class Logging < Feature
|
|
12
24
|
HTTP::Options.register_feature(:logging, self)
|
|
13
25
|
|
|
26
|
+
# No-op logger used as default when none is provided
|
|
14
27
|
class NullLogger
|
|
15
28
|
%w[fatal error warn info debug].each do |level|
|
|
16
29
|
define_method(level.to_sym) do |*_args|
|
|
@@ -23,31 +36,196 @@ module HTTP
|
|
|
23
36
|
end
|
|
24
37
|
end
|
|
25
38
|
|
|
39
|
+
# The logger instance
|
|
40
|
+
#
|
|
41
|
+
# @example
|
|
42
|
+
# feature.logger
|
|
43
|
+
#
|
|
44
|
+
# @return [#info, #debug] the logger instance
|
|
45
|
+
# @api public
|
|
26
46
|
attr_reader :logger
|
|
27
47
|
|
|
28
|
-
|
|
48
|
+
# Initializes the Logging feature
|
|
49
|
+
#
|
|
50
|
+
# @example
|
|
51
|
+
# Logging.new(logger: Logger.new(STDOUT))
|
|
52
|
+
#
|
|
53
|
+
# @example With binary formatter
|
|
54
|
+
# Logging.new(logger: Logger.new(STDOUT), binary_formatter: :base64)
|
|
55
|
+
#
|
|
56
|
+
# @param logger [#info, #debug] logger instance
|
|
57
|
+
# @param binary_formatter [:stats, :base64, #call] how to log binary bodies
|
|
58
|
+
# @return [Logging]
|
|
59
|
+
# @api public
|
|
60
|
+
def initialize(logger: NullLogger.new, binary_formatter: :stats)
|
|
61
|
+
super()
|
|
29
62
|
@logger = logger
|
|
63
|
+
@binary_formatter = validate_binary_formatter!(binary_formatter)
|
|
30
64
|
end
|
|
31
65
|
|
|
66
|
+
# Logs and returns the request
|
|
67
|
+
#
|
|
68
|
+
# @example
|
|
69
|
+
# feature.wrap_request(request)
|
|
70
|
+
#
|
|
71
|
+
# @param request [HTTP::Request]
|
|
72
|
+
# @return [HTTP::Request]
|
|
73
|
+
# @api public
|
|
32
74
|
def wrap_request(request)
|
|
33
|
-
logger.info { ">
|
|
34
|
-
|
|
75
|
+
logger.info { format("> %s %s", String(request.verb).upcase, request.uri) }
|
|
76
|
+
log_request_details(request)
|
|
35
77
|
|
|
36
78
|
request
|
|
37
79
|
end
|
|
38
80
|
|
|
81
|
+
# Logs and returns the response
|
|
82
|
+
#
|
|
83
|
+
# @example
|
|
84
|
+
# feature.wrap_response(response)
|
|
85
|
+
#
|
|
86
|
+
# @param response [HTTP::Response]
|
|
87
|
+
# @return [HTTP::Response]
|
|
88
|
+
# @api public
|
|
39
89
|
def wrap_response(response)
|
|
40
90
|
logger.info { "< #{response.status}" }
|
|
41
|
-
logger.debug { "#{stringify_headers(response.headers)}\n\n#{response.body}" }
|
|
42
91
|
|
|
43
|
-
response
|
|
92
|
+
return log_response_body_inline(response) unless response.body.is_a?(Response::Body)
|
|
93
|
+
|
|
94
|
+
logger.debug { stringify_headers(response.headers) }
|
|
95
|
+
return response unless logger.debug?
|
|
96
|
+
|
|
97
|
+
Response.new(**logged_response_options(response)) # steep:ignore
|
|
44
98
|
end
|
|
45
99
|
|
|
46
100
|
private
|
|
47
101
|
|
|
102
|
+
# Validate and return the binary_formatter option
|
|
103
|
+
# @return [:stats, :base64, #call]
|
|
104
|
+
# @raise [ArgumentError] if the formatter is not a valid option
|
|
105
|
+
# @api private
|
|
106
|
+
def validate_binary_formatter!(formatter)
|
|
107
|
+
return formatter if formatter.eql?(:stats) || formatter.eql?(:base64) || formatter.respond_to?(:call)
|
|
108
|
+
|
|
109
|
+
raise ArgumentError,
|
|
110
|
+
"binary_formatter must be :stats, :base64, or a callable " \
|
|
111
|
+
"(got #{formatter.inspect})"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Log request headers and body (when loggable)
|
|
115
|
+
# @return [void]
|
|
116
|
+
# @api private
|
|
117
|
+
def log_request_details(request)
|
|
118
|
+
headers = stringify_headers(request.headers)
|
|
119
|
+
if request.body.loggable?
|
|
120
|
+
source = request.body.source
|
|
121
|
+
body = source.encoding.eql?(Encoding::BINARY) ? format_binary(source) : source
|
|
122
|
+
logger.debug { "#{headers}\n\n#{body}" }
|
|
123
|
+
else
|
|
124
|
+
logger.debug { headers }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Log response with body inline (for non-streaming string bodies)
|
|
129
|
+
# @return [HTTP::Response]
|
|
130
|
+
# @api private
|
|
131
|
+
def log_response_body_inline(response)
|
|
132
|
+
body = response.body
|
|
133
|
+
headers = stringify_headers(response.headers)
|
|
134
|
+
if body.respond_to?(:encoding) && body.encoding.eql?(Encoding::BINARY)
|
|
135
|
+
logger.debug { "#{headers}\n\n#{format_binary(body)}" } # steep:ignore
|
|
136
|
+
else
|
|
137
|
+
logger.debug { "#{headers}\n\n#{body}" }
|
|
138
|
+
end
|
|
139
|
+
response
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Build options hash for a response with body logging
|
|
143
|
+
# @return [Hash]
|
|
144
|
+
# @api private
|
|
145
|
+
def logged_response_options(response)
|
|
146
|
+
{
|
|
147
|
+
status: response.status,
|
|
148
|
+
version: response.version,
|
|
149
|
+
headers: response.headers,
|
|
150
|
+
proxy_headers: response.proxy_headers,
|
|
151
|
+
connection: response.connection,
|
|
152
|
+
body: logged_body(response.body),
|
|
153
|
+
request: response.request
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Wrap a response body with a logging stream
|
|
158
|
+
# @return [HTTP::Response::Body]
|
|
159
|
+
# @api private
|
|
160
|
+
def logged_body(body)
|
|
161
|
+
formatter = (method(:format_binary) unless body.loggable?) # steep:ignore
|
|
162
|
+
stream = BodyLogger.new(body.instance_variable_get(:@stream), logger, formatter: formatter) # steep:ignore
|
|
163
|
+
Response::Body.new(stream, encoding: body.encoding)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Format binary data according to the configured binary_formatter
|
|
167
|
+
# @return [String]
|
|
168
|
+
# @api private
|
|
169
|
+
def format_binary(data)
|
|
170
|
+
case @binary_formatter
|
|
171
|
+
when :stats
|
|
172
|
+
format("BINARY DATA (%d bytes)", data.bytesize)
|
|
173
|
+
when :base64
|
|
174
|
+
format("BINARY DATA (%d bytes)\n%s", data.bytesize, [data].pack("m0"))
|
|
175
|
+
else
|
|
176
|
+
@binary_formatter.call(data) # steep:ignore
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Convert headers to a string representation
|
|
181
|
+
# @return [String]
|
|
182
|
+
# @api private
|
|
48
183
|
def stringify_headers(headers)
|
|
49
184
|
headers.map { |name, value| "#{name}: #{value}" }.join("\n")
|
|
50
185
|
end
|
|
186
|
+
|
|
187
|
+
# Stream wrapper that logs each chunk as it flows through readpartial
|
|
188
|
+
class BodyLogger
|
|
189
|
+
# The underlying connection
|
|
190
|
+
#
|
|
191
|
+
# @example
|
|
192
|
+
# body_logger.connection
|
|
193
|
+
#
|
|
194
|
+
# @return [HTTP::Connection] the underlying connection
|
|
195
|
+
# @api public
|
|
196
|
+
attr_reader :connection
|
|
197
|
+
|
|
198
|
+
# Create a new BodyLogger wrapping a stream
|
|
199
|
+
#
|
|
200
|
+
# @example
|
|
201
|
+
# BodyLogger.new(stream, logger)
|
|
202
|
+
#
|
|
203
|
+
# @param stream [#readpartial] the stream to wrap
|
|
204
|
+
# @param logger [#debug] the logger instance
|
|
205
|
+
# @param formatter [#call, nil] optional formatter for each chunk
|
|
206
|
+
# @return [BodyLogger]
|
|
207
|
+
# @api public
|
|
208
|
+
def initialize(stream, logger, formatter: nil)
|
|
209
|
+
@stream = stream
|
|
210
|
+
@connection = stream.respond_to?(:connection) ? stream.connection : stream
|
|
211
|
+
@logger = logger
|
|
212
|
+
@formatter = formatter
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Read a chunk from the underlying stream and log it
|
|
216
|
+
#
|
|
217
|
+
# @example
|
|
218
|
+
# body_logger.readpartial # => "chunk"
|
|
219
|
+
#
|
|
220
|
+
# @return [String] the chunk read from the stream
|
|
221
|
+
# @raise [EOFError] when no more data left
|
|
222
|
+
# @api public
|
|
223
|
+
def readpartial(*)
|
|
224
|
+
chunk = @stream.readpartial(*)
|
|
225
|
+
@logger.debug { @formatter ? @formatter.call(chunk) : chunk } # steep:ignore
|
|
226
|
+
chunk
|
|
227
|
+
end
|
|
228
|
+
end
|
|
51
229
|
end
|
|
52
230
|
end
|
|
53
231
|
end
|
|
@@ -4,10 +4,27 @@ require "http/uri"
|
|
|
4
4
|
|
|
5
5
|
module HTTP
|
|
6
6
|
module Features
|
|
7
|
+
# Normalizes request URIs before sending
|
|
7
8
|
class NormalizeUri < Feature
|
|
9
|
+
# The URI normalizer proc
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# feature.normalizer
|
|
13
|
+
#
|
|
14
|
+
# @return [#call] the URI normalizer proc
|
|
15
|
+
# @api public
|
|
8
16
|
attr_reader :normalizer
|
|
9
17
|
|
|
18
|
+
# Initializes the NormalizeUri feature
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# NormalizeUri.new(normalizer: HTTP::URI::NORMALIZER)
|
|
22
|
+
#
|
|
23
|
+
# @param normalizer [#call] URI normalizer
|
|
24
|
+
# @return [NormalizeUri]
|
|
25
|
+
# @api public
|
|
10
26
|
def initialize(normalizer: HTTP::URI::NORMALIZER)
|
|
27
|
+
super()
|
|
11
28
|
@normalizer = normalizer
|
|
12
29
|
end
|
|
13
30
|
|
|
@@ -2,18 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
module HTTP
|
|
4
4
|
module Features
|
|
5
|
+
# Raises an error for non-successful HTTP responses
|
|
5
6
|
class RaiseError < Feature
|
|
7
|
+
# Initializes the RaiseError feature
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# RaiseError.new(ignore: [404])
|
|
11
|
+
#
|
|
12
|
+
# @param ignore [Array<Integer>] status codes to ignore
|
|
13
|
+
# @return [RaiseError]
|
|
14
|
+
# @api public
|
|
6
15
|
def initialize(ignore: [])
|
|
7
|
-
super()
|
|
8
|
-
|
|
9
16
|
@ignore = ignore
|
|
10
17
|
end
|
|
11
18
|
|
|
19
|
+
# Raises an error for non-successful responses
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# feature.wrap_response(response)
|
|
23
|
+
#
|
|
24
|
+
# @param response [HTTP::Response]
|
|
25
|
+
# @return [HTTP::Response]
|
|
26
|
+
# @api public
|
|
12
27
|
def wrap_response(response)
|
|
13
28
|
return response if response.code < 400
|
|
14
29
|
return response if @ignore.include?(response.code)
|
|
15
30
|
|
|
16
|
-
raise
|
|
31
|
+
raise StatusError, response
|
|
17
32
|
end
|
|
18
33
|
|
|
19
34
|
HTTP::Options.register_feature(:raise_error, self)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module HTTP
|
|
6
|
+
module FormData
|
|
7
|
+
# Provides IO interface across multiple IO objects.
|
|
8
|
+
class CompositeIO
|
|
9
|
+
# Creates a new CompositeIO from an array of IOs
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# CompositeIO.new([StringIO.new("hello"), StringIO.new(" world")])
|
|
13
|
+
#
|
|
14
|
+
# @api public
|
|
15
|
+
# @param [Array<IO>] ios Array of IO objects
|
|
16
|
+
def initialize(ios)
|
|
17
|
+
@index = 0
|
|
18
|
+
@ios = ios.map do |io|
|
|
19
|
+
if io.is_a?(String)
|
|
20
|
+
StringIO.new(io)
|
|
21
|
+
elsif io.respond_to?(:read)
|
|
22
|
+
io
|
|
23
|
+
else
|
|
24
|
+
raise ArgumentError,
|
|
25
|
+
"#{io.inspect} is neither a String nor an IO object"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Reads and returns content across multiple IO objects
|
|
31
|
+
#
|
|
32
|
+
# @example
|
|
33
|
+
# composite_io.read # => "hello world"
|
|
34
|
+
# composite_io.read(5) # => "hello"
|
|
35
|
+
#
|
|
36
|
+
# @api public
|
|
37
|
+
# @param [Integer] length Number of bytes to retrieve
|
|
38
|
+
# @param [String] outbuf String to be replaced with retrieved data
|
|
39
|
+
# @return [String, nil]
|
|
40
|
+
def read(length = nil, outbuf = nil)
|
|
41
|
+
data = outbuf.clear.force_encoding(Encoding::BINARY) if outbuf
|
|
42
|
+
data ||= "".b
|
|
43
|
+
|
|
44
|
+
read_chunks(length) { |chunk| data << chunk }
|
|
45
|
+
|
|
46
|
+
data unless length && data.empty?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns sum of all IO sizes
|
|
50
|
+
#
|
|
51
|
+
# @example
|
|
52
|
+
# composite_io.size # => 11
|
|
53
|
+
#
|
|
54
|
+
# @api public
|
|
55
|
+
# @return [Integer]
|
|
56
|
+
def size
|
|
57
|
+
@size ||= @ios.sum(&:size)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Rewinds all IO objects and resets cursor
|
|
61
|
+
#
|
|
62
|
+
# @example
|
|
63
|
+
# composite_io.rewind
|
|
64
|
+
#
|
|
65
|
+
# @api public
|
|
66
|
+
# @return [void]
|
|
67
|
+
def rewind
|
|
68
|
+
@ios.each(&:rewind)
|
|
69
|
+
@index = 0
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Yields chunks with total length up to `length`
|
|
75
|
+
#
|
|
76
|
+
# @api private
|
|
77
|
+
# @return [void]
|
|
78
|
+
def read_chunks(length)
|
|
79
|
+
while (chunk = readpartial(length))
|
|
80
|
+
yield chunk.force_encoding(Encoding::BINARY)
|
|
81
|
+
|
|
82
|
+
next if length.nil?
|
|
83
|
+
|
|
84
|
+
remaining = length - chunk.bytesize
|
|
85
|
+
break if remaining.zero?
|
|
86
|
+
|
|
87
|
+
length = remaining
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Reads chunk from current IO with length up to `max_length`
|
|
92
|
+
#
|
|
93
|
+
# @api private
|
|
94
|
+
# @return [String, nil]
|
|
95
|
+
def readpartial(max_length)
|
|
96
|
+
while (io = @ios.at(@index))
|
|
97
|
+
chunk = io.read(max_length)
|
|
98
|
+
|
|
99
|
+
return chunk if chunk && !chunk.empty?
|
|
100
|
+
|
|
101
|
+
@index += 1
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HTTP
|
|
4
|
+
module FormData
|
|
5
|
+
# Represents file form param.
|
|
6
|
+
#
|
|
7
|
+
# @example Usage with StringIO
|
|
8
|
+
#
|
|
9
|
+
# io = StringIO.new "foo bar baz"
|
|
10
|
+
# FormData::File.new io, filename: "foobar.txt"
|
|
11
|
+
#
|
|
12
|
+
# @example Usage with IO
|
|
13
|
+
#
|
|
14
|
+
# File.open "/home/ixti/avatar.png" do |io|
|
|
15
|
+
# FormData::File.new io
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @example Usage with pathname
|
|
19
|
+
#
|
|
20
|
+
# FormData::File.new "/home/ixti/avatar.png"
|
|
21
|
+
class File < Part
|
|
22
|
+
# Default MIME type
|
|
23
|
+
DEFAULT_MIME = "application/octet-stream"
|
|
24
|
+
|
|
25
|
+
# Creates a new File from a path or IO object
|
|
26
|
+
#
|
|
27
|
+
# @example
|
|
28
|
+
# File.new("/path/to/file.txt")
|
|
29
|
+
#
|
|
30
|
+
# @api public
|
|
31
|
+
# @see DEFAULT_MIME
|
|
32
|
+
# @param [String, Pathname, IO] path_or_io Filename or IO instance
|
|
33
|
+
# @param [#to_h] opts
|
|
34
|
+
# @option opts [#to_s] :content_type (DEFAULT_MIME)
|
|
35
|
+
# Value of Content-Type header
|
|
36
|
+
# @option opts [#to_s] :filename
|
|
37
|
+
# When `path_or_io` is a String, Pathname or File, defaults to basename.
|
|
38
|
+
# When `path_or_io` is a IO, defaults to `"stream-{object_id}"`
|
|
39
|
+
def initialize(path_or_io, opts = nil) # rubocop:disable Lint/MissingSuper
|
|
40
|
+
opts = FormData.ensure_hash(opts)
|
|
41
|
+
|
|
42
|
+
@io = make_io(path_or_io)
|
|
43
|
+
@autoclose = path_or_io.is_a?(String) || path_or_io.is_a?(Pathname)
|
|
44
|
+
@content_type = opts.fetch(:content_type, DEFAULT_MIME).to_s
|
|
45
|
+
@filename = opts.fetch(:filename, filename_for(@io))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Closes the underlying IO if it was opened by this instance
|
|
49
|
+
#
|
|
50
|
+
# When the File was created from a String path or Pathname, the
|
|
51
|
+
# underlying file handle is closed. When created from an existing
|
|
52
|
+
# IO object, this is a no-op (the caller is responsible for
|
|
53
|
+
# closing it).
|
|
54
|
+
#
|
|
55
|
+
# @example
|
|
56
|
+
# file = FormData::File.new("/path/to/file.txt")
|
|
57
|
+
# file.to_s
|
|
58
|
+
# file.close
|
|
59
|
+
#
|
|
60
|
+
# @api public
|
|
61
|
+
# @return [void]
|
|
62
|
+
def close
|
|
63
|
+
@io.close if @autoclose
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# Wraps path_or_io into an IO object
|
|
69
|
+
#
|
|
70
|
+
# @api private
|
|
71
|
+
# @param [String, Pathname, IO] path_or_io
|
|
72
|
+
# @return [IO]
|
|
73
|
+
def make_io(path_or_io)
|
|
74
|
+
case path_or_io
|
|
75
|
+
when String then ::File.new(path_or_io, binmode: true)
|
|
76
|
+
when Pathname then path_or_io.open(binmode: true)
|
|
77
|
+
else path_or_io
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Determines filename for the given IO
|
|
82
|
+
#
|
|
83
|
+
# @api private
|
|
84
|
+
# @param [IO] io
|
|
85
|
+
# @return [String]
|
|
86
|
+
def filename_for(io)
|
|
87
|
+
if io.respond_to?(:path)
|
|
88
|
+
::File.basename(io.path)
|
|
89
|
+
else
|
|
90
|
+
"stream-#{io.object_id}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "http/form_data/readable"
|
|
4
|
+
require "http/form_data/composite_io"
|
|
5
|
+
|
|
6
|
+
module HTTP
|
|
7
|
+
module FormData
|
|
8
|
+
class Multipart
|
|
9
|
+
# Utility class to represent multi-part chunks
|
|
10
|
+
class Param
|
|
11
|
+
include Readable
|
|
12
|
+
|
|
13
|
+
# Initializes body part with headers and data
|
|
14
|
+
#
|
|
15
|
+
# @example With {FormData::File} value
|
|
16
|
+
#
|
|
17
|
+
# Content-Disposition: form-data; name="avatar"; filename="avatar.png"
|
|
18
|
+
# Content-Type: application/octet-stream
|
|
19
|
+
#
|
|
20
|
+
# ...data of avatar.png...
|
|
21
|
+
#
|
|
22
|
+
# @example With non-{FormData::File} value
|
|
23
|
+
#
|
|
24
|
+
# Content-Disposition: form-data; name="username"
|
|
25
|
+
#
|
|
26
|
+
# ixti
|
|
27
|
+
#
|
|
28
|
+
# @api public
|
|
29
|
+
# @param [#to_s] name
|
|
30
|
+
# @param [FormData::File, FormData::Part, #to_s] value
|
|
31
|
+
# @return [Param]
|
|
32
|
+
def initialize(name, value)
|
|
33
|
+
@name = name.to_s
|
|
34
|
+
@part = value.is_a?(Part) ? value : Part.new(value)
|
|
35
|
+
@io = CompositeIO.new [header, @part, CRLF]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Builds the MIME header for this part
|
|
41
|
+
#
|
|
42
|
+
# @api private
|
|
43
|
+
# @return [String]
|
|
44
|
+
def header
|
|
45
|
+
header = "Content-Disposition: form-data; #{parameters}#{CRLF}"
|
|
46
|
+
header << "Content-Type: #{@part.content_type}#{CRLF}" if @part.content_type
|
|
47
|
+
header << CRLF
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Builds Content-Disposition parameters string
|
|
51
|
+
#
|
|
52
|
+
# @api private
|
|
53
|
+
# @return [String]
|
|
54
|
+
def parameters
|
|
55
|
+
params = "name=#{@name.inspect}"
|
|
56
|
+
params << "; filename=#{@part.filename.inspect}" if @part.filename
|
|
57
|
+
params
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
require "http/form_data/multipart/param"
|
|
6
|
+
require "http/form_data/readable"
|
|
7
|
+
require "http/form_data/composite_io"
|
|
8
|
+
|
|
9
|
+
module HTTP
|
|
10
|
+
module FormData
|
|
11
|
+
# `multipart/form-data` form data.
|
|
12
|
+
class Multipart
|
|
13
|
+
include Readable
|
|
14
|
+
|
|
15
|
+
# Default MIME type for multipart form data
|
|
16
|
+
DEFAULT_CONTENT_TYPE = "multipart/form-data"
|
|
17
|
+
|
|
18
|
+
# Returns the multipart boundary string
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# multipart.boundary # => "-----abc123"
|
|
22
|
+
#
|
|
23
|
+
# @api public
|
|
24
|
+
# @return [String]
|
|
25
|
+
attr_reader :boundary
|
|
26
|
+
|
|
27
|
+
# Creates a new Multipart form data instance
|
|
28
|
+
#
|
|
29
|
+
# @example Basic form data
|
|
30
|
+
# Multipart.new({ foo: "bar" })
|
|
31
|
+
#
|
|
32
|
+
# @example With custom content type
|
|
33
|
+
# Multipart.new(parts, content_type: "multipart/related")
|
|
34
|
+
#
|
|
35
|
+
# @api public
|
|
36
|
+
# @param [Enumerable, Hash, #to_h] data form data key-value pairs
|
|
37
|
+
# @param [String] boundary custom boundary string
|
|
38
|
+
# @param [String] content_type MIME type for the Content-Type header
|
|
39
|
+
def initialize(data, boundary: self.class.generate_boundary, content_type: DEFAULT_CONTENT_TYPE)
|
|
40
|
+
@boundary = boundary.to_s.freeze
|
|
41
|
+
@content_type = content_type
|
|
42
|
+
@io = CompositeIO.new(parts(data).flat_map { |part| [glue, part] } << tail)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Generates a boundary string for multipart form data
|
|
46
|
+
#
|
|
47
|
+
# @example
|
|
48
|
+
# Multipart.generate_boundary # => "-----abc123..."
|
|
49
|
+
#
|
|
50
|
+
# @api public
|
|
51
|
+
# @return [String]
|
|
52
|
+
def self.generate_boundary
|
|
53
|
+
("-" * 21) << SecureRandom.hex(21)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns MIME type for the Content-Type header
|
|
57
|
+
#
|
|
58
|
+
# @example
|
|
59
|
+
# multipart.content_type
|
|
60
|
+
# # => "multipart/form-data; boundary=-----abc123"
|
|
61
|
+
#
|
|
62
|
+
# @api public
|
|
63
|
+
# @return [String]
|
|
64
|
+
def content_type
|
|
65
|
+
"#{@content_type}; boundary=#{@boundary}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Returns form data content size for Content-Length
|
|
69
|
+
#
|
|
70
|
+
# @example
|
|
71
|
+
# multipart.content_length # => 123
|
|
72
|
+
#
|
|
73
|
+
# @api public
|
|
74
|
+
# @return [Integer]
|
|
75
|
+
alias content_length size
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Returns the boundary glue between parts
|
|
80
|
+
#
|
|
81
|
+
# @api private
|
|
82
|
+
# @return [String]
|
|
83
|
+
def glue
|
|
84
|
+
@glue ||= "--#{@boundary}#{CRLF}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Returns the closing boundary tail
|
|
88
|
+
#
|
|
89
|
+
# @api private
|
|
90
|
+
# @return [String]
|
|
91
|
+
def tail
|
|
92
|
+
@tail ||= "--#{@boundary}--#{CRLF}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Coerces data into an array of Param objects
|
|
96
|
+
#
|
|
97
|
+
# @api private
|
|
98
|
+
# @return [Array<Param>]
|
|
99
|
+
def parts(data)
|
|
100
|
+
FormData.ensure_data(data).flat_map do |name, values|
|
|
101
|
+
Array(values).map { |value| Param.new(name, value) }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|