multipart supported by default

the plugin was now moved to the transcoder layer, where it is available
from the get-go.
This commit is contained in:
HoneyryderChuck 2023-07-04 23:05:38 +01:00
parent 896914e189
commit 3f73d2e3ce
21 changed files with 471 additions and 563 deletions

View File

@ -7,6 +7,7 @@
* `:total_timeout` option has been removed (no session-wide timeout supported, use `:request_timeout`).
* `:read_timeout` and `:write_timeout` are now set to 60 seconds by default, and preferred over `:operation_timeout`;
* the exception being in the `:stream` plugin, as the response is theoretically endless (so `:read_timeout` is unset).
* The `:multipart` plugin is now loaded by default.
### plugins

View File

@ -1,96 +0,0 @@
# frozen_string_literal: true
module HTTPX
module Plugins
#
# This plugin adds support for passing `http-form_data` objects (like file objects) as "multipart/form-data";
#
# HTTPX.post(URL, form: form: { image: HTTP::FormData::File.new("path/to/file")})
#
# https://gitlab.com/os85/httpx/wikis/Multipart-Uploads
#
module Multipart
MULTIPART_VALUE_COND = lambda do |value|
value.respond_to?(:read) ||
(value.respond_to?(:to_hash) &&
value.key?(:body) &&
(value.key?(:filename) || value.key?(:content_type)))
end
class << self
def normalize_keys(key, value, &block)
Transcoder.normalize_keys(key, value, MULTIPART_VALUE_COND, &block)
end
def load_dependencies(*)
# :nocov:
begin
unless defined?(HTTP::FormData)
# in order not to break legacy code, we'll keep loading http/form_data for them.
require "http/form_data"
warn "httpx: http/form_data is no longer a requirement to use HTTPX :multipart plugin. See migration instructions under" \
"https://os85.gitlab.io/httpx/wiki/Multipart-Uploads.html#notes. \n\n" \
"If you'd like to stop seeing this message, require 'http/form_data' yourself."
end
rescue LoadError
end
# :nocov:
require "httpx/plugins/multipart/encoder"
require "httpx/plugins/multipart/decoder"
require "httpx/plugins/multipart/part"
require "httpx/plugins/multipart/mime_type_detector"
end
end
module RequestBodyMethods
private
def initialize_body(options)
return FormTranscoder.encode(options.form) if options.form
super
end
end
module ResponseMethods
def form
decode(FormTranscoder)
end
end
module FormTranscoder
module_function
def encode(form)
if multipart?(form)
Encoder.new(form)
else
Transcoder::Form::Encoder.new(form)
end
end
def decode(response)
content_type = response.content_type.mime_type
case content_type
when "application/x-www-form-urlencoded"
Transcoder::Form.decode(response)
when "multipart/form-data"
Decoder.new(response)
else
raise Error, "invalid form mime type (#{content_type})"
end
end
def multipart?(data)
data.any? do |_, v|
MULTIPART_VALUE_COND.call(v) ||
(v.respond_to?(:to_ary) && v.to_ary.any?(&MULTIPART_VALUE_COND)) ||
(v.respond_to?(:to_hash) && v.to_hash.any? { |_, e| MULTIPART_VALUE_COND.call(e) })
end
end
end
end
register_plugin :multipart, Multipart
end
end

View File

@ -1,137 +0,0 @@
# frozen_string_literal: true
require "tempfile"
require "delegate"
module HTTPX::Plugins
module Multipart
class FilePart < SimpleDelegator
attr_reader :original_filename, :content_type
def initialize(filename, content_type)
@original_filename = filename
@content_type = content_type
@file = Tempfile.new("httpx", encoding: Encoding::BINARY, mode: File::RDWR)
super(@file)
end
end
class Decoder
include HTTPX::Utils
CRLF = "\r\n"
BOUNDARY_RE = /;\s*boundary=([^;]+)/i.freeze
MULTIPART_CONTENT_TYPE = /Content-Type: (.*)#{CRLF}/ni.freeze
MULTIPART_CONTENT_DISPOSITION = /Content-Disposition:.*;\s*name=(#{VALUE})/ni.freeze
MULTIPART_CONTENT_ID = /Content-ID:\s*([^#{CRLF}]*)/ni.freeze
WINDOW_SIZE = 2 << 14
def initialize(response)
@boundary = begin
m = response.headers["content-type"].to_s[BOUNDARY_RE, 1]
raise Error, "no boundary declared in content-type header" unless m
m.strip
end
@buffer = "".b
@parts = {}
@intermediate_boundary = "--#{@boundary}"
@state = :idle
end
def call(response, *)
response.body.each do |chunk|
@buffer << chunk
parse
end
raise Error, "invalid or unsupported multipart format" unless @buffer.empty?
@parts
end
private
def parse
case @state
when :idle
raise Error, "payload does not start with boundary" unless @buffer.start_with?("#{@intermediate_boundary}#{CRLF}")
@buffer = @buffer.byteslice(@intermediate_boundary.bytesize + 2..-1)
@state = :part_header
when :part_header
idx = @buffer.index("#{CRLF}#{CRLF}")
# raise Error, "couldn't parse part headers" unless idx
return unless idx
head = @buffer.byteslice(0..idx + 4 - 1)
@buffer = @buffer.byteslice(head.bytesize..-1)
content_type = head[MULTIPART_CONTENT_TYPE, 1]
if (name = head[MULTIPART_CONTENT_DISPOSITION, 1])
name = /\A"(.*)"\Z/ =~ name ? Regexp.last_match(1) : name.dup
name.gsub!(/\\(.)/, "\\1")
name
else
name = head[MULTIPART_CONTENT_ID, 1]
end
filename = HTTPX::Utils.get_filename(head)
name = filename || +"#{content_type || "text/plain"}[]" if name.nil? || name.empty?
@current = name
@parts[name] = if filename
FilePart.new(filename, content_type)
else
"".b
end
@state = :part_body
when :part_body
part = @parts[@current]
body_separator = if part.is_a?(FilePart)
"#{CRLF}#{CRLF}"
else
CRLF
end
idx = @buffer.index(body_separator)
if idx
payload = @buffer.byteslice(0..idx - 1)
@buffer = @buffer.byteslice(idx + body_separator.bytesize..-1)
part << payload
part.rewind if part.respond_to?(:rewind)
@state = :parse_boundary
else
part << @buffer
@buffer.clear
end
when :parse_boundary
raise Error, "payload does not start with boundary" unless @buffer.start_with?(@intermediate_boundary)
@buffer = @buffer.byteslice(@intermediate_boundary.bytesize..-1)
if @buffer == "--"
@buffer.clear
@state = :done
return
elsif @buffer.start_with?(CRLF)
@buffer = @buffer.byteslice(2..-1)
@state = :part_header
else
return
end
when :done
raise Error, "parsing should have been over by now"
end until @buffer.empty?
end
end
end
end

View File

@ -2,57 +2,77 @@
require "forwardable"
require "uri"
require_relative "multipart"
module HTTPX::Transcoder
module Form
module_function
module HTTPX
module Transcoder
module Form
module_function
PARAM_DEPTH_LIMIT = 32
PARAM_DEPTH_LIMIT = 32
class Encoder
extend Forwardable
class Encoder
extend Forwardable
def_delegator :@raw, :to_s
def_delegator :@raw, :to_s
def_delegator :@raw, :to_str
def_delegator :@raw, :to_str
def_delegator :@raw, :bytesize
def_delegator :@raw, :bytesize
def initialize(form)
@raw = form.each_with_object("".b) do |(key, val), buf|
HTTPX::Transcoder.normalize_keys(key, val) do |k, v|
buf << "&" unless buf.empty?
buf << URI.encode_www_form_component(k)
buf << "=#{URI.encode_www_form_component(v.to_s)}" unless v.nil?
def initialize(form)
@raw = form.each_with_object("".b) do |(key, val), buf|
HTTPX::Transcoder.normalize_keys(key, val) do |k, v|
buf << "&" unless buf.empty?
buf << URI.encode_www_form_component(k)
buf << "=#{URI.encode_www_form_component(v.to_s)}" unless v.nil?
end
end
end
def content_type
"application/x-www-form-urlencoded"
end
end
module Decoder
module_function
def call(response, *)
URI.decode_www_form(response.to_s).each_with_object({}) do |(field, value), params|
HTTPX::Transcoder.normalize_query(params, field, value, PARAM_DEPTH_LIMIT)
end
end
end
def content_type
"application/x-www-form-urlencoded"
def encode(form)
if multipart?(form)
Multipart::Encoder.new(form)
else
Encoder.new(form)
end
end
end
module Decoder
module_function
def decode(response)
content_type = response.content_type.mime_type
def call(response, *)
URI.decode_www_form(response.to_s).each_with_object({}) do |(field, value), params|
HTTPX::Transcoder.normalize_query(params, field, value, PARAM_DEPTH_LIMIT)
case content_type
when "application/x-www-form-urlencoded"
Decoder
when "multipart/form-data"
Multipart::Decoder.new(response)
else
raise Error, "invalid form mime type (#{content_type})"
end
end
def multipart?(data)
data.any? do |_, v|
Multipart::MULTIPART_VALUE_COND.call(v) ||
(v.respond_to?(:to_ary) && v.to_ary.any?(&Multipart::MULTIPART_VALUE_COND)) ||
(v.respond_to?(:to_hash) && v.to_hash.any? { |_, e| Multipart::MULTIPART_VALUE_COND.call(e) })
end
end
end
def encode(form)
Encoder.new(form)
end
def decode(response)
content_type = response.content_type.mime_type
raise HTTPX::Error, "invalid form mime type (#{content_type})" unless content_type == "application/x-www-form-urlencoded"
Decoder
end
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
require_relative "multipart/encoder"
require_relative "multipart/decoder"
require_relative "multipart/part"
require_relative "multipart/mime_type_detector"
module HTTPX::Transcoder
module Multipart
MULTIPART_VALUE_COND = lambda do |value|
value.respond_to?(:read) ||
(value.respond_to?(:to_hash) &&
value.key?(:body) &&
(value.key?(:filename) || value.key?(:content_type)))
end
end
end

View File

@ -0,0 +1,139 @@
# frozen_string_literal: true
require "tempfile"
require "delegate"
module HTTPX
module Transcoder
module Multipart
class FilePart < SimpleDelegator
attr_reader :original_filename, :content_type
def initialize(filename, content_type)
@original_filename = filename
@content_type = content_type
@file = Tempfile.new("httpx", encoding: Encoding::BINARY, mode: File::RDWR)
super(@file)
end
end
class Decoder
include HTTPX::Utils
CRLF = "\r\n"
BOUNDARY_RE = /;\s*boundary=([^;]+)/i.freeze
MULTIPART_CONTENT_TYPE = /Content-Type: (.*)#{CRLF}/ni.freeze
MULTIPART_CONTENT_DISPOSITION = /Content-Disposition:.*;\s*name=(#{VALUE})/ni.freeze
MULTIPART_CONTENT_ID = /Content-ID:\s*([^#{CRLF}]*)/ni.freeze
WINDOW_SIZE = 2 << 14
def initialize(response)
@boundary = begin
m = response.headers["content-type"].to_s[BOUNDARY_RE, 1]
raise Error, "no boundary declared in content-type header" unless m
m.strip
end
@buffer = "".b
@parts = {}
@intermediate_boundary = "--#{@boundary}"
@state = :idle
end
def call(response, *)
response.body.each do |chunk|
@buffer << chunk
parse
end
raise Error, "invalid or unsupported multipart format" unless @buffer.empty?
@parts
end
private
def parse
case @state
when :idle
raise Error, "payload does not start with boundary" unless @buffer.start_with?("#{@intermediate_boundary}#{CRLF}")
@buffer = @buffer.byteslice(@intermediate_boundary.bytesize + 2..-1)
@state = :part_header
when :part_header
idx = @buffer.index("#{CRLF}#{CRLF}")
# raise Error, "couldn't parse part headers" unless idx
return unless idx
head = @buffer.byteslice(0..idx + 4 - 1)
@buffer = @buffer.byteslice(head.bytesize..-1)
content_type = head[MULTIPART_CONTENT_TYPE, 1]
if (name = head[MULTIPART_CONTENT_DISPOSITION, 1])
name = /\A"(.*)"\Z/ =~ name ? Regexp.last_match(1) : name.dup
name.gsub!(/\\(.)/, "\\1")
name
else
name = head[MULTIPART_CONTENT_ID, 1]
end
filename = HTTPX::Utils.get_filename(head)
name = filename || +"#{content_type || "text/plain"}[]" if name.nil? || name.empty?
@current = name
@parts[name] = if filename
FilePart.new(filename, content_type)
else
"".b
end
@state = :part_body
when :part_body
part = @parts[@current]
body_separator = if part.is_a?(FilePart)
"#{CRLF}#{CRLF}"
else
CRLF
end
idx = @buffer.index(body_separator)
if idx
payload = @buffer.byteslice(0..idx - 1)
@buffer = @buffer.byteslice(idx + body_separator.bytesize..-1)
part << payload
part.rewind if part.respond_to?(:rewind)
@state = :parse_boundary
else
part << @buffer
@buffer.clear
end
when :parse_boundary
raise Error, "payload does not start with boundary" unless @buffer.start_with?(@intermediate_boundary)
@buffer = @buffer.byteslice(@intermediate_boundary.bytesize..-1)
if @buffer == "--"
@buffer.clear
@state = :done
return
elsif @buffer.start_with?(CRLF)
@buffer = @buffer.byteslice(2..-1)
@state = :part_header
else
return
end
when :done
raise Error, "parsing should have been over by now"
end until @buffer.empty?
end
end
end
end
end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module HTTPX::Plugins
module Multipart
module HTTPX
module Transcoder::Multipart
class Encoder
attr_reader :bytesize
@ -43,7 +43,7 @@ module HTTPX::Plugins
def to_parts(form)
@bytesize = 0
params = form.each_with_object([]) do |(key, val), aux|
Multipart.normalize_keys(key, val) do |k, v|
Transcoder.normalize_keys(key, val, MULTIPART_VALUE_COND) do |k, v|
next if v.nil?
value, content_type, filename = Part.call(v)

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module HTTPX
module Plugins::Multipart
module Transcoder::Multipart
module MimeTypeDetector
module_function

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module HTTPX
module Plugins::Multipart
module Transcoder::Multipart
module Part
module_function

View File

@ -9,8 +9,7 @@ class Bug_0_14_1_Test < Minitest::Test
def test_multipart_can_have_arbitrary_content_type
uri = "https://#{httpbin}/post"
response = HTTPX.plugin(:multipart)
.post(uri, form: {
response = HTTPX.post(uri, form: {
image: {
content_type: "image/png",
body: File.new(fixture_file_path),

View File

@ -9,8 +9,7 @@ class Bug_0_14_2_Test < Minitest::Test
def test_multipart_can_have_arbitrary_filename
uri = "https://#{httpbin}/post"
response = HTTPX.plugin(:multipart)
.post(uri, form: {
response = HTTPX.post(uri, form: {
image: {
filename: "weird-al-jankovic",
body: File.new(fixture_file_path),

View File

@ -23,7 +23,6 @@ module HTTPX
| (:follow_redirects, ?options) -> Plugins::sessionFollowRedirects
| (:upgrade, ?options) -> Session
| (:h2c, ?options) -> Session
| (:multipart, ?options) -> Session
| (:persistent, ?options) -> Plugins::sessionPersistent
| (:proxy, ?options) -> (Plugins::sessionProxy & Plugins::httpProxy)
| (:push_promise, ?options) -> Plugins::sessionPushPromise

View File

@ -3,11 +3,12 @@ module HTTPX::Transcoder
type form_nested_value = form_value | _ToAry[form_value] | _ToHash[string, form_value]
type urlencoded_input = Enumerable[[_ToS, form_nested_value]]
type urlencoded_input = Enumerable[[_ToS, form_nested_value | Multipart::multipart_nested_value]]
module Form
def self?.encode: (urlencoded_input form) -> Encoder
def self?.encode: (urlencoded_input form) -> (Encoder | Multipart::Encoder)
def self?.decode: (HTTPX::Response response) -> _Decoder
def self?.multipart?: (form_nested_value | Multipart::multipart_nested_value data) -> bool
class Encoder
extend Forwardable

View File

@ -1,5 +1,5 @@
module HTTPX
module Plugins
module Transcoder
module Multipart
interface _MultipartInput
def filename: () -> String
@ -9,13 +9,6 @@ module HTTPX
MULTIPART_VALUE_COND: ^(_Reader | record_multipart_value value) -> bool
def self.load_dependencies: (singleton(Session)) -> void
def self.configure: (*untyped) -> void
def self?.encode: (untyped) -> (Encoder | Transcoder::Form::Encoder)
def self?.decode: (HTTPX::Response response) -> Transcoder::_Decoder
def self?.normalize_keys: [U] (_ToS key, _ToAry[untyped] | _ToHash[_ToS, untyped] | untyped value) { (String, ?untyped) -> U } -> U
type multipart_value = string | Pathname | File | _Reader
type record_multipart_value = { content_type: String, filename: String, body: multipart_value } |

View File

@ -14,8 +14,7 @@ class MultipartFilemagicTest < Minitest::Test
filemagic_spy = Spy.on(FileMagic, :open).and_call_through
response = HTTPX.plugin(:multipart)
.post("https://#{httpbin}/post", form: { image: File.new(fixture_file_path) })
response = HTTPX.post("https://#{httpbin}/post", form: { image: File.new(fixture_file_path) })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")

View File

@ -14,8 +14,7 @@ class MultipartMarcelTest < Minitest::Test
marcel_spy = Spy.on(Marcel::MimeType, :for).and_call_through
response = HTTPX.plugin(:multipart)
.post("https://#{httpbin}/post", form: { image: File.new(fixture_file_path) })
response = HTTPX.post("https://#{httpbin}/post", form: { image: File.new(fixture_file_path) })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")

View File

@ -14,8 +14,7 @@ class MultipartMimemagicTest < Minitest::Test
mimemagic_spy = Spy.on(MimeMagic, :by_magic).and_call_through
response = HTTPX.plugin(:multipart)
.post("https://#{httpbin}/post", form: { image: File.new(fixture_file_path) })
response = HTTPX.post("https://#{httpbin}/post", form: { image: File.new(fixture_file_path) })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")

View File

@ -10,6 +10,7 @@ class HTTPTest < Minitest::Test
include ChunkedGet
include WithBody
include WithChunkedBody
include Multipart
include Headers
include ResponseBody
include IO
@ -25,7 +26,6 @@ class HTTPTest < Minitest::Test
include Plugins::Compression
include Plugins::H2C
include Plugins::Retries
include Plugins::Multipart
include Plugins::Expect
include Plugins::RateLimiter
include Plugins::Stream

View File

@ -8,6 +8,7 @@ class HTTPSTest < Minitest::Test
include Get
include Head
include WithBody
include Multipart
include Headers
include ResponseBody
include IO
@ -25,7 +26,6 @@ class HTTPSTest < Minitest::Test
include Plugins::Compression
include Plugins::PushPromise if OpenSSL::SSL::SSLContext.instance_methods.include?(:alpn_protocols)
include Plugins::Retries
include Plugins::Multipart
include Plugins::Expect
include Plugins::RateLimiter
include Plugins::Persistent

View File

@ -0,0 +1,243 @@
# frozen_string_literal: true
require "http/form_data"
module Requests
module Multipart
%w[post put patch delete].each do |meth|
define_method :"test_multipart_urlencoded_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.send(meth, uri, form: { "foo" => "bar" })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "application/x-www-form-urlencoded")
verify_uploaded(body, "form", "foo" => "bar")
end
define_method :"test_multipart_nested_urlencoded_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.send(meth, uri, form: { "q" => { "a" => "z" }, "a" => %w[1 2] })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "application/x-www-form-urlencoded")
verify_uploaded(body, "form", "q[a]" => "z", "a[]" => %w[1 2])
end
define_method :"test_multipart_repeated_field_urlencoded_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.send(meth, uri, form: [%w[foo bar1], %w[foo bar2]])
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "application/x-www-form-urlencoded")
verify_uploaded(body, "form", "foo" => %w[bar1 bar2])
end
define_method :"test_multipart_hash_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.send(meth, uri, form: { metadata: { content_type: "application/json", body: JSON.dump({ a: 1 }) } })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
assert JSON.parse(body["form"]["metadata"], symbolize_names: true) == { a: 1 }
end
define_method :"test_multipart_nested_hash_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.send(meth, uri, form: { q: { metadata: { content_type: "application/json", body: JSON.dump({ a: 1 }) } } })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
assert JSON.parse(body["form"]["q[metadata]"], symbolize_names: true) == { a: 1 }
end
define_method :"test_multipart_nested_array_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.send(meth, uri, form: { q: [{ content_type: "application/json", body: JSON.dump({ a: 1 }) }] })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
assert JSON.parse(body["form"]["q[]"], symbolize_names: true) == { a: 1 }
end
define_method :"test_multipart_file_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.send(meth, uri, form: { image: File.new(fixture_file_path) })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
verify_uploaded_image(body, "image", "image/jpeg")
end
define_method :"test_multipart_file_repeated_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.send(meth, uri, form: [
%w[foo bar1],
["image1", File.new(fixture_file_path)],
%w[foo bar2],
["image2", File.new(fixture_file_path)],
])
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
verify_uploaded(body, "form", "foo" => %w[bar1 bar2])
verify_uploaded_image(body, "image1", "image/jpeg")
verify_uploaded_image(body, "image2", "image/jpeg")
end
define_method :"test_multipart_nested_file_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.send(meth, uri, form: { q: { image: File.new(fixture_file_path) } })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
verify_uploaded_image(body, "q[image]", "image/jpeg")
end
define_method :"test_multipart_nested_ary_file_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.send(meth, uri, form: { images: [File.new(fixture_file_path)] })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
verify_uploaded_image(body, "images[]", "image/jpeg")
end
define_method :"test_multipart_filename_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.send(meth, uri, form: { image: { filename: "selfie", body: File.new(fixture_file_path) } })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
verify_uploaded_image(body, "image", "image/jpeg")
# TODO: find out how to check the filename given.
end
define_method :"test_multipart_nested_filename_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.send(meth, uri, form: { q: { image: { filename: "selfie", body: File.new(fixture_file_path) } } })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
verify_uploaded_image(body, "q[image]", "image/jpeg")
# TODO: find out how to check the filename given.
end
define_method :"test_multipart_nested_filename_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.send(meth, uri, form: { q: { image: File.new(fixture_file_path) } })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
verify_uploaded_image(body, "q[image]", "image/jpeg")
end
define_method :"test_multipart_pathname_#{meth}" do
uri = build_uri("/#{meth}")
file = Pathname.new(fixture_file_path)
response = HTTPX.send(meth, uri, form: { image: file })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
verify_uploaded_image(body, "image", "image/jpeg")
end
define_method :"test_multipart_nested_pathname_#{meth}" do
uri = build_uri("/#{meth}")
file = Pathname.new(fixture_file_path)
response = HTTPX.send(meth, uri, form: { q: { image: file } })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
verify_uploaded_image(body, "q[image]", "image/jpeg")
end
define_method :"test_multipart_http_formdata_#{meth}" do
uri = build_uri("/#{meth}")
file = HTTP::FormData::File.new(fixture_file_path, content_type: "image/jpeg")
response = HTTPX.send(meth, uri, form: { image: file })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
verify_uploaded_image(body, "image", "image/jpeg")
end
define_method :"test_multipart_nested_http_formdata_#{meth}" do
uri = build_uri("/#{meth}")
file = HTTP::FormData::File.new(fixture_file_path, content_type: "image/jpeg")
response = HTTPX.send(meth, uri, form: { q: { image: file } })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
verify_uploaded_image(body, "q[image]", "image/jpeg")
end
define_method :"test_multipart_spoofed_file_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.send(meth, uri, form: { image: {
content_type: "image/jpeg",
filename: "selfie",
body: "spoofpeg",
} })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
# httpbin accepts the spoofed part, but it wipes our the content-type header
verify_uploaded_image(body, "image", "spoofpeg", skip_verify_data: true)
end
end
# safety-check test only check if request is successfully rewinded
def test_multipart_retry_file_post
check_error = lambda { |response|
(response.is_a?(HTTPX::ErrorResponse) && response.error.is_a?(HTTPX::TimeoutError)) || response.status == 405
}
uri = build_uri("/delay/4")
retries_session = HTTPX.plugin(RequestInspector)
.plugin(:retries, max_retries: 1, retry_on: check_error) # because CI...
.with_timeout(request_timeout: 2)
retries_response = retries_session.post(uri, retry_change_requests: true, form: { image: File.new(fixture_file_path) })
assert check_error[retries_response], "expected #{retries_response} to be an error response"
assert retries_session.calls == 1, "expect request to be retried 1 time (was #{retries_session.calls})"
end
def test_multipart_response_decoder
form_response = HTTPX::Response.new(
HTTPX::Request.new("GET", "http://example.com"),
200,
"2.0",
{ "content-type" => "multipart/form-data; boundary=90" }
)
form_response << [
"--90\r\n",
"Content-Disposition: form-data; name=\"text\"\r\n\r\n",
"text default\r\n",
"--90\r\n",
"Content-Disposition: form-data; name=\"file1\"; filename=\"a.txt\"\r\n",
"Content-Type: text/plain\r\n\r\n",
"Content of a.txt.\r\n\r\n",
"--90\r\n",
"Content-Disposition: form-data; name=\"file2\"; filename=\"a.html\"\r\n",
"Content-Type: text/html\r\n\r\n",
"<!DOCTYPE html><title>Content of a.html.</title>\r\n\r\n",
"--90--",
].join
form = form_response.form
begin
assert form["text"] == "text default"
assert form["file1"].original_filename == "a.txt"
assert form["file1"].content_type == "text/plain"
assert form["file1"].read == "Content of a.txt."
assert form["file2"].original_filename == "a.html"
assert form["file2"].content_type == "text/html"
assert form["file2"].read == "<!DOCTYPE html><title>Content of a.html.</title>"
ensure
form["file1"].close
form["file1"].unlink
form["file2"].close
form["file2"].unlink
end
end
end
end

View File

@ -1,267 +0,0 @@
# frozen_string_literal: true
require "http/form_data"
module Requests
module Plugins
module Multipart
%w[post put patch delete].each do |meth|
define_method :"test_plugin_multipart_urlencoded_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.plugin(:multipart)
.send(meth, uri, form: { "foo" => "bar" })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "application/x-www-form-urlencoded")
verify_uploaded(body, "form", "foo" => "bar")
end
define_method :"test_plugin_multipart_nested_urlencoded_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.plugin(:multipart)
.send(meth, uri, form: { "q" => { "a" => "z" }, "a" => %w[1 2] })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "application/x-www-form-urlencoded")
verify_uploaded(body, "form", "q[a]" => "z", "a[]" => %w[1 2])
end
define_method :"test_plugin_multipart_repeated_field_urlencoded_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.plugin(:multipart)
.send(meth, uri, form: [%w[foo bar1], %w[foo bar2]])
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "application/x-www-form-urlencoded")
verify_uploaded(body, "form", "foo" => %w[bar1 bar2])
end
define_method :"test_plugin_multipart_hash_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.plugin(:multipart)
.send(meth, uri, form: { metadata: { content_type: "application/json", body: JSON.dump({ a: 1 }) } })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
assert JSON.parse(body["form"]["metadata"], symbolize_names: true) == { a: 1 }
end
define_method :"test_plugin_multipart_nested_hash_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.plugin(:multipart)
.send(meth, uri, form: { q: { metadata: { content_type: "application/json", body: JSON.dump({ a: 1 }) } } })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
assert JSON.parse(body["form"]["q[metadata]"], symbolize_names: true) == { a: 1 }
end
define_method :"test_plugin_multipart_nested_array_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.plugin(:multipart)
.send(meth, uri, form: { q: [{ content_type: "application/json", body: JSON.dump({ a: 1 }) }] })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
assert JSON.parse(body["form"]["q[]"], symbolize_names: true) == { a: 1 }
end
define_method :"test_plugin_multipart_file_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.plugin(:multipart)
.send(meth, uri, form: { image: File.new(fixture_file_path) })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
verify_uploaded_image(body, "image", "image/jpeg")
end
define_method :"test_plugin_multipart_file_repeated_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.plugin(:multipart)
.send(meth, uri, form: [
%w[foo bar1],
["image1", File.new(fixture_file_path)],
%w[foo bar2],
["image2", File.new(fixture_file_path)],
])
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
verify_uploaded(body, "form", "foo" => %w[bar1 bar2])
verify_uploaded_image(body, "image1", "image/jpeg")
verify_uploaded_image(body, "image2", "image/jpeg")
end
define_method :"test_plugin_multipart_nested_file_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.plugin(:multipart)
.send(meth, uri, form: { q: { image: File.new(fixture_file_path) } })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
verify_uploaded_image(body, "q[image]", "image/jpeg")
end
define_method :"test_plugin_multipart_nested_ary_file_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.plugin(:multipart)
.send(meth, uri, form: { images: [File.new(fixture_file_path)] })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
verify_uploaded_image(body, "images[]", "image/jpeg")
end
define_method :"test_plugin_multipart_filename_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.plugin(:multipart)
.send(meth, uri, form: { image: { filename: "selfie", body: File.new(fixture_file_path) } })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
verify_uploaded_image(body, "image", "image/jpeg")
# TODO: find out how to check the filename given.
end
define_method :"test_plugin_multipart_nested_filename_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.plugin(:multipart)
.send(meth, uri, form: { q: { image: { filename: "selfie", body: File.new(fixture_file_path) } } })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
verify_uploaded_image(body, "q[image]", "image/jpeg")
# TODO: find out how to check the filename given.
end
define_method :"test_plugin_multipart_nested_filename_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.plugin(:multipart)
.send(meth, uri, form: { q: { image: File.new(fixture_file_path) } })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
verify_uploaded_image(body, "q[image]", "image/jpeg")
end
define_method :"test_plugin_multipart_pathname_#{meth}" do
uri = build_uri("/#{meth}")
file = Pathname.new(fixture_file_path)
response = HTTPX.plugin(:multipart)
.send(meth, uri, form: { image: file })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
verify_uploaded_image(body, "image", "image/jpeg")
end
define_method :"test_plugin_multipart_nested_pathname_#{meth}" do
uri = build_uri("/#{meth}")
file = Pathname.new(fixture_file_path)
response = HTTPX.plugin(:multipart)
.send(meth, uri, form: { q: { image: file } })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
verify_uploaded_image(body, "q[image]", "image/jpeg")
end
define_method :"test_plugin_multipart_http_formdata_#{meth}" do
uri = build_uri("/#{meth}")
file = HTTP::FormData::File.new(fixture_file_path, content_type: "image/jpeg")
response = HTTPX.plugin(:multipart)
.send(meth, uri, form: { image: file })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
verify_uploaded_image(body, "image", "image/jpeg")
end
define_method :"test_plugin_multipart_nested_http_formdata_#{meth}" do
uri = build_uri("/#{meth}")
file = HTTP::FormData::File.new(fixture_file_path, content_type: "image/jpeg")
response = HTTPX.plugin(:multipart)
.send(meth, uri, form: { q: { image: file } })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
verify_uploaded_image(body, "q[image]", "image/jpeg")
end
define_method :"test_plugin_multipart_spoofed_file_#{meth}" do
uri = build_uri("/#{meth}")
response = HTTPX.plugin(:multipart)
.send(meth, uri, form: { image: {
content_type: "image/jpeg",
filename: "selfie",
body: "spoofpeg",
} })
verify_status(response, 200)
body = json_body(response)
verify_header(body["headers"], "Content-Type", "multipart/form-data")
# httpbin accepts the spoofed part, but it wipes our the content-type header
verify_uploaded_image(body, "image", "spoofpeg", skip_verify_data: true)
end
end
# safety-check test only check if request is successfully rewinded
def test_plugin_multipart_retry_file_post
check_error = lambda { |response|
(response.is_a?(HTTPX::ErrorResponse) && response.error.is_a?(HTTPX::TimeoutError)) || response.status == 405
}
uri = build_uri("/delay/4")
retries_session = HTTPX.plugin(RequestInspector)
.plugin(:retries, max_retries: 1, retry_on: check_error) # because CI...
.with_timeout(request_timeout: 2)
.plugin(:multipart)
retries_response = retries_session.post(uri, retry_change_requests: true, form: { image: File.new(fixture_file_path) })
assert check_error[retries_response], "expected #{retries_response} to be an error response"
assert retries_session.calls == 1, "expect request to be retried 1 time (was #{retries_session.calls})"
end
def test_plugin_multipart_response_decoder
form_response = HTTPX.plugin(:multipart)
.class.default_options
.response_class
.new(
HTTPX::Request.new("GET", "http://example.com"),
200,
"2.0",
{ "content-type" => "multipart/form-data; boundary=90" }
)
form_response << [
"--90\r\n",
"Content-Disposition: form-data; name=\"text\"\r\n\r\n",
"text default\r\n",
"--90\r\n",
"Content-Disposition: form-data; name=\"file1\"; filename=\"a.txt\"\r\n",
"Content-Type: text/plain\r\n\r\n",
"Content of a.txt.\r\n\r\n",
"--90\r\n",
"Content-Disposition: form-data; name=\"file2\"; filename=\"a.html\"\r\n",
"Content-Type: text/html\r\n\r\n",
"<!DOCTYPE html><title>Content of a.html.</title>\r\n\r\n",
"--90--",
].join
form = form_response.form
begin
assert form["text"] == "text default"
assert form["file1"].original_filename == "a.txt"
assert form["file1"].content_type == "text/plain"
assert form["file1"].read == "Content of a.txt."
assert form["file2"].original_filename == "a.html"
assert form["file2"].content_type == "text/html"
assert form["file2"].read == "<!DOCTYPE html><title>Content of a.html.</title>"
ensure
form["file1"].close
form["file1"].unlink
form["file2"].close
form["file2"].unlink
end
end
end
end
end