Skip to content

Commit f7aa60a

Browse files
committed
Merge form_data into http
1 parent e68bddc commit f7aa60a

20 files changed

Lines changed: 2238 additions & 6 deletions

.mutant.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,5 +111,3 @@ matcher:
111111
# Cookie/CookieJar delegate to http-cookie gem
112112
- HTTP::Cookie*
113113
- HTTP::CookieJar*
114-
# External gem (http-form_data)
115-
- HTTP::FormData*

http.gemspec

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,15 @@ Gem::Specification.new do |spec|
2929
extras = %w[CHANGELOG.md CONTRIBUTING.md LICENSE.txt README.md SECURITY.md UPGRADING.md] << File.basename(__FILE__)
3030

3131
ls.readlines("\x0", chomp: true).select do |f|
32-
f.start_with?("lib/", "test/") || extras.include?(f) || f.eql?("sig/http.rbs")
32+
f.start_with?("lib/", "test/", "sig/") || extras.include?(f)
3333
end
3434
end
3535

3636
spec.require_paths = ["lib"]
3737

3838
spec.required_ruby_version = ">= 3.2"
3939

40-
spec.add_dependency "http-cookie", "~> 1.0"
41-
spec.add_dependency "http-form_data", "~> 2.2"
40+
spec.add_dependency "http-cookie", "~> 1.0"
4241

4342
if RUBY_ENGINE == "jruby"
4443
spec.platform = "java" if ENV["HTTP_PLATFORM"] == "java"

lib/http/form_data.rb

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# frozen_string_literal: true
2+
3+
require "http/form_data/part"
4+
require "http/form_data/file"
5+
require "http/form_data/multipart"
6+
require "http/form_data/urlencoded"
7+
require "http/form_data/version"
8+
9+
# http gem namespace.
10+
# @see https://github.com/httprb/http
11+
module HTTP
12+
# Utility-belt to build form data request bodies.
13+
# Provides support for `application/x-www-form-urlencoded` and
14+
# `multipart/form-data` types.
15+
#
16+
# @example Usage
17+
#
18+
# form = FormData.create({
19+
# username: "ixti",
20+
# avatar_file: FormData::File.new("/home/ixti/avatar.png")
21+
# })
22+
#
23+
# # Assuming socket is an open socket to some HTTP server
24+
# socket << "POST /some-url HTTP/1.1\r\n"
25+
# socket << "Host: example.com\r\n"
26+
# socket << "Content-Type: #{form.content_type}\r\n"
27+
# socket << "Content-Length: #{form.content_length}\r\n"
28+
# socket << "\r\n"
29+
# socket << form.to_s
30+
module FormData
31+
# CRLF
32+
CRLF = "\r\n"
33+
34+
# Generic FormData error.
35+
class Error < StandardError; end
36+
37+
class << self
38+
# Selects encoder type based on given data
39+
#
40+
# @example
41+
# FormData.create({ username: "ixti" })
42+
#
43+
# @api public
44+
# @param [Enumerable, Hash, #to_h] data
45+
# @return [Multipart] if any of values is a {FormData::File}
46+
# @return [Urlencoded] otherwise
47+
def create(data, encoder: nil)
48+
data = ensure_data data
49+
50+
if multipart?(data)
51+
Multipart.new(data)
52+
else
53+
Urlencoded.new(data, encoder: encoder)
54+
end
55+
end
56+
57+
# Coerces obj to Hash
58+
#
59+
# @example
60+
# FormData.ensure_hash({ foo: :bar }) # => { foo: :bar }
61+
#
62+
# @api public
63+
# @raise [Error] `obj` can't be coerced
64+
# @return [Hash]
65+
def ensure_hash(obj)
66+
if obj.is_a?(Hash) then obj
67+
elsif obj.respond_to?(:to_h) then obj.to_h
68+
else raise Error, "#{obj.inspect} is neither Hash nor responds to :to_h"
69+
end
70+
end
71+
72+
# Coerces obj to an Enumerable of key-value pairs
73+
#
74+
# @example
75+
# FormData.ensure_data([[:foo, :bar]]) # => [[:foo, :bar]]
76+
#
77+
# @api public
78+
# @raise [Error] `obj` can't be coerced
79+
# @return [Enumerable]
80+
def ensure_data(obj)
81+
if obj.nil? then []
82+
elsif obj.is_a?(Enumerable) then obj
83+
elsif obj.respond_to?(:to_h) then obj.to_h
84+
else raise Error, "#{obj.inspect} is neither Enumerable nor responds to :to_h"
85+
end
86+
end
87+
88+
private
89+
90+
# Checks if data contains multipart data
91+
#
92+
# @api private
93+
# @param [Enumerable] data
94+
# @return [Boolean]
95+
def multipart?(data)
96+
data.any? do |_, v|
97+
v.is_a?(Part) || (v.respond_to?(:to_ary) && v.to_ary.any?(Part))
98+
end
99+
end
100+
end
101+
end
102+
end

lib/http/form_data/composite_io.rb

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

lib/http/form_data/file.rb

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 commit comments

Comments
 (0)