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.
Files changed (201) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +241 -41
  3. data/LICENSE.txt +1 -1
  4. data/README.md +110 -13
  5. data/UPGRADING.md +491 -0
  6. data/http.gemspec +32 -29
  7. data/lib/http/base64.rb +11 -1
  8. data/lib/http/chainable/helpers.rb +62 -0
  9. data/lib/http/chainable/verbs.rb +136 -0
  10. data/lib/http/chainable.rb +232 -136
  11. data/lib/http/client.rb +158 -127
  12. data/lib/http/connection/internals.rb +141 -0
  13. data/lib/http/connection.rb +126 -97
  14. data/lib/http/content_type.rb +61 -6
  15. data/lib/http/errors.rb +25 -1
  16. data/lib/http/feature.rb +65 -5
  17. data/lib/http/features/auto_deflate.rb +124 -17
  18. data/lib/http/features/auto_inflate.rb +38 -15
  19. data/lib/http/features/caching/entry.rb +178 -0
  20. data/lib/http/features/caching/in_memory_store.rb +63 -0
  21. data/lib/http/features/caching.rb +216 -0
  22. data/lib/http/features/digest_auth.rb +234 -0
  23. data/lib/http/features/instrumentation.rb +97 -17
  24. data/lib/http/features/logging.rb +183 -5
  25. data/lib/http/features/normalize_uri.rb +17 -0
  26. data/lib/http/features/raise_error.rb +18 -3
  27. data/lib/http/form_data/composite_io.rb +106 -0
  28. data/lib/http/form_data/file.rb +95 -0
  29. data/lib/http/form_data/multipart/param.rb +62 -0
  30. data/lib/http/form_data/multipart.rb +106 -0
  31. data/lib/http/form_data/part.rb +52 -0
  32. data/lib/http/form_data/readable.rb +58 -0
  33. data/lib/http/form_data/urlencoded.rb +175 -0
  34. data/lib/http/form_data/version.rb +8 -0
  35. data/lib/http/form_data.rb +102 -0
  36. data/lib/http/headers/known.rb +3 -0
  37. data/lib/http/headers/normalizer.rb +17 -36
  38. data/lib/http/headers.rb +172 -65
  39. data/lib/http/mime_type/adapter.rb +24 -9
  40. data/lib/http/mime_type/json.rb +19 -4
  41. data/lib/http/mime_type.rb +21 -3
  42. data/lib/http/options/definitions.rb +189 -0
  43. data/lib/http/options.rb +172 -125
  44. data/lib/http/redirector.rb +80 -75
  45. data/lib/http/request/body.rb +87 -6
  46. data/lib/http/request/builder.rb +184 -0
  47. data/lib/http/request/proxy.rb +83 -0
  48. data/lib/http/request/writer.rb +76 -16
  49. data/lib/http/request.rb +214 -98
  50. data/lib/http/response/body.rb +103 -18
  51. data/lib/http/response/inflater.rb +35 -7
  52. data/lib/http/response/parser.rb +98 -4
  53. data/lib/http/response/status/reasons.rb +2 -4
  54. data/lib/http/response/status.rb +141 -31
  55. data/lib/http/response.rb +219 -61
  56. data/lib/http/retriable/delay_calculator.rb +38 -11
  57. data/lib/http/retriable/errors.rb +21 -0
  58. data/lib/http/retriable/performer.rb +82 -38
  59. data/lib/http/session.rb +280 -0
  60. data/lib/http/timeout/global.rb +147 -34
  61. data/lib/http/timeout/null.rb +155 -9
  62. data/lib/http/timeout/per_operation.rb +139 -18
  63. data/lib/http/uri/normalizer.rb +82 -0
  64. data/lib/http/uri/parsing.rb +182 -0
  65. data/lib/http/uri.rb +289 -124
  66. data/lib/http/version.rb +2 -1
  67. data/lib/http.rb +11 -2
  68. data/sig/deps.rbs +122 -0
  69. data/sig/http.rbs +1619 -0
  70. data/test/http/base64_test.rb +28 -0
  71. data/test/http/client_test.rb +739 -0
  72. data/test/http/connection_test.rb +1533 -0
  73. data/test/http/content_type_test.rb +190 -0
  74. data/test/http/errors_test.rb +28 -0
  75. data/test/http/feature_test.rb +49 -0
  76. data/test/http/features/auto_deflate_test.rb +317 -0
  77. data/test/http/features/auto_inflate_test.rb +213 -0
  78. data/test/http/features/caching_test.rb +942 -0
  79. data/test/http/features/digest_auth_test.rb +996 -0
  80. data/test/http/features/instrumentation_test.rb +246 -0
  81. data/test/http/features/logging_test.rb +654 -0
  82. data/test/http/features/normalize_uri_test.rb +41 -0
  83. data/test/http/features/raise_error_test.rb +77 -0
  84. data/test/http/form_data/composite_io_test.rb +215 -0
  85. data/test/http/form_data/file_test.rb +255 -0
  86. data/test/http/form_data/fixtures/the-http-gem.info +1 -0
  87. data/test/http/form_data/multipart_test.rb +303 -0
  88. data/test/http/form_data/part_test.rb +90 -0
  89. data/test/http/form_data/urlencoded_test.rb +164 -0
  90. data/test/http/form_data_test.rb +232 -0
  91. data/test/http/headers/normalizer_test.rb +93 -0
  92. data/test/http/headers_test.rb +888 -0
  93. data/test/http/mime_type/json_test.rb +39 -0
  94. data/test/http/mime_type_test.rb +150 -0
  95. data/test/http/options/base_uri_test.rb +148 -0
  96. data/test/http/options/body_test.rb +21 -0
  97. data/test/http/options/features_test.rb +38 -0
  98. data/test/http/options/form_test.rb +21 -0
  99. data/test/http/options/headers_test.rb +32 -0
  100. data/test/http/options/json_test.rb +21 -0
  101. data/test/http/options/merge_test.rb +78 -0
  102. data/test/http/options/new_test.rb +37 -0
  103. data/test/http/options/proxy_test.rb +32 -0
  104. data/test/http/options_test.rb +575 -0
  105. data/test/http/redirector_test.rb +639 -0
  106. data/test/http/request/body_test.rb +318 -0
  107. data/test/http/request/builder_test.rb +623 -0
  108. data/test/http/request/writer_test.rb +391 -0
  109. data/test/http/request_test.rb +1733 -0
  110. data/test/http/response/body_test.rb +292 -0
  111. data/test/http/response/parser_test.rb +105 -0
  112. data/test/http/response/status_test.rb +322 -0
  113. data/test/http/response_test.rb +502 -0
  114. data/test/http/retriable/delay_calculator_test.rb +194 -0
  115. data/test/http/retriable/errors_test.rb +71 -0
  116. data/test/http/retriable/performer_test.rb +551 -0
  117. data/test/http/session_test.rb +424 -0
  118. data/test/http/timeout/global_test.rb +239 -0
  119. data/test/http/timeout/null_test.rb +218 -0
  120. data/test/http/timeout/per_operation_test.rb +220 -0
  121. data/test/http/uri/normalizer_test.rb +89 -0
  122. data/test/http/uri_test.rb +1140 -0
  123. data/test/http/version_test.rb +15 -0
  124. data/test/http_test.rb +818 -0
  125. data/test/regression_tests.rb +27 -0
  126. data/test/support/dummy_server/encoding_routes.rb +47 -0
  127. data/test/support/dummy_server/routes.rb +201 -0
  128. data/test/support/dummy_server/servlet.rb +81 -0
  129. data/test/support/dummy_server.rb +200 -0
  130. data/{spec → test}/support/fakeio.rb +2 -2
  131. data/test/support/http_handling_shared/connection_reuse_tests.rb +97 -0
  132. data/test/support/http_handling_shared/timeout_tests.rb +134 -0
  133. data/test/support/http_handling_shared.rb +11 -0
  134. data/test/support/proxy_server.rb +207 -0
  135. data/test/support/servers/runner.rb +67 -0
  136. data/{spec → test}/support/simplecov.rb +11 -2
  137. data/test/support/ssl_helper.rb +108 -0
  138. data/test/test_helper.rb +38 -0
  139. metadata +108 -168
  140. data/.github/workflows/ci.yml +0 -67
  141. data/.gitignore +0 -15
  142. data/.rspec +0 -1
  143. data/.rubocop/layout.yml +0 -8
  144. data/.rubocop/metrics.yml +0 -4
  145. data/.rubocop/rspec.yml +0 -9
  146. data/.rubocop/style.yml +0 -32
  147. data/.rubocop.yml +0 -11
  148. data/.rubocop_todo.yml +0 -219
  149. data/.yardopts +0 -2
  150. data/CHANGES_OLD.md +0 -1002
  151. data/Gemfile +0 -51
  152. data/Guardfile +0 -18
  153. data/Rakefile +0 -64
  154. data/lib/http/headers/mixin.rb +0 -34
  155. data/lib/http/retriable/client.rb +0 -37
  156. data/logo.png +0 -0
  157. data/spec/lib/http/client_spec.rb +0 -556
  158. data/spec/lib/http/connection_spec.rb +0 -88
  159. data/spec/lib/http/content_type_spec.rb +0 -47
  160. data/spec/lib/http/features/auto_deflate_spec.rb +0 -77
  161. data/spec/lib/http/features/auto_inflate_spec.rb +0 -86
  162. data/spec/lib/http/features/instrumentation_spec.rb +0 -81
  163. data/spec/lib/http/features/logging_spec.rb +0 -65
  164. data/spec/lib/http/features/raise_error_spec.rb +0 -62
  165. data/spec/lib/http/headers/mixin_spec.rb +0 -36
  166. data/spec/lib/http/headers/normalizer_spec.rb +0 -52
  167. data/spec/lib/http/headers_spec.rb +0 -527
  168. data/spec/lib/http/options/body_spec.rb +0 -15
  169. data/spec/lib/http/options/features_spec.rb +0 -33
  170. data/spec/lib/http/options/form_spec.rb +0 -15
  171. data/spec/lib/http/options/headers_spec.rb +0 -24
  172. data/spec/lib/http/options/json_spec.rb +0 -15
  173. data/spec/lib/http/options/merge_spec.rb +0 -68
  174. data/spec/lib/http/options/new_spec.rb +0 -30
  175. data/spec/lib/http/options/proxy_spec.rb +0 -20
  176. data/spec/lib/http/options_spec.rb +0 -13
  177. data/spec/lib/http/redirector_spec.rb +0 -530
  178. data/spec/lib/http/request/body_spec.rb +0 -211
  179. data/spec/lib/http/request/writer_spec.rb +0 -121
  180. data/spec/lib/http/request_spec.rb +0 -234
  181. data/spec/lib/http/response/body_spec.rb +0 -85
  182. data/spec/lib/http/response/parser_spec.rb +0 -74
  183. data/spec/lib/http/response/status_spec.rb +0 -253
  184. data/spec/lib/http/response_spec.rb +0 -262
  185. data/spec/lib/http/retriable/delay_calculator_spec.rb +0 -69
  186. data/spec/lib/http/retriable/performer_spec.rb +0 -302
  187. data/spec/lib/http/uri/normalizer_spec.rb +0 -95
  188. data/spec/lib/http/uri_spec.rb +0 -71
  189. data/spec/lib/http_spec.rb +0 -535
  190. data/spec/regression_specs.rb +0 -24
  191. data/spec/spec_helper.rb +0 -89
  192. data/spec/support/black_hole.rb +0 -13
  193. data/spec/support/dummy_server/servlet.rb +0 -203
  194. data/spec/support/dummy_server.rb +0 -44
  195. data/spec/support/fuubar.rb +0 -21
  196. data/spec/support/http_handling_shared.rb +0 -190
  197. data/spec/support/proxy_server.rb +0 -39
  198. data/spec/support/servers/config.rb +0 -11
  199. data/spec/support/servers/runner.rb +0 -19
  200. data/spec/support/ssl_helper.rb +0 -104
  201. /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
- def initialize(logger: NullLogger.new)
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 { "> #{request.verb.to_s.upcase} #{request.uri}" }
34
- logger.debug { "#{stringify_headers(request.headers)}\n\n#{request.body.source}" }
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 HTTP::StatusError, response
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