lib/chef-api/connection.rb in chef-api-0.3.0 vs lib/chef-api/connection.rb in chef-api-0.4.0

- old
+ new

@@ -202,11 +202,20 @@ # Setup PATCH/POST/PUT if [:patch, :post, :put].include?(verb) if data.respond_to?(:read) request.body_stream = data elsif data.is_a?(Hash) - request.form_data = data + # If any of the values in the hash are File-like, assume this is a + # multi-part post + if data.values.any? { |value| value.respond_to?(:read) } + multipart = Multipart::Body.new(data) + request.content_length = multipart.content_length + request.content_type = multipart.content_type + request.body_stream = multipart.stream + else + request.form_data = data + end else request.body = data end end @@ -498,9 +507,170 @@ ).sign(key) headers.each do |key, value| log.debug "#{key}: #{value}" request[key] = value + end + end + end + + require 'cgi' + require 'mime/types' + + module Multipart + BOUNDARY = '------ChefAPIMultipartBoundary'.freeze + + class Body + def initialize(params = {}) + params.each do |key, value| + if value.respond_to?(:read) + parts << FilePart.new(key, value) + else + parts << ParamPart.new(key, value) + end + end + + parts << EndingPart.new + end + + def stream + MultiIO.new(*parts.map(&:io)) + end + + def content_type + "multipart/form-data; boundary=#{BOUNDARY}" + end + + def content_length + parts.map(&:size).inject(:+) + end + + private + + def parts + @parts ||= [] + end + end + + class MultiIO + def initialize(*ios) + @ios = ios + @index = 0 + end + + # Read from IOs in order until `length` bytes have been received. + def read(length = nil, outbuf = nil) + got_result = false + outbuf = outbuf ? outbuf.replace('') : '' + + while io = current_io + if result = io.read(length) + got_result ||= !result.nil? + result.force_encoding('BINARY') if result.respond_to?(:force_encoding) + outbuf << result + length -= result.length if length + break if length == 0 + end + advance_io + end + + (!got_result && length) ? nil : outbuf + end + + def rewind + @ios.each { |io| io.rewind } + @index = 0 + end + + private + + def current_io + @ios[@index] + end + + def advance_io + @index += 1 + end + end + + # + # A generic key => value part. + # + class ParamPart + def initialize(name, value) + @part = build(name, value) + end + + def io + @io ||= StringIO.new(@part) + end + + def size + @part.bytesize + end + + private + + def build(name, value) + part = %|--#{BOUNDARY}\r\n| + part << %|Content-Disposition: form-data; name="#{CGI.escape(name)}"\r\n\r\n| + part << %|#{value}\r\n| + part + end + end + + # + # A File part + # + class FilePart + def initialize(name, file) + @file = file + @head = build(name, file) + @foot = "\r\n" + end + + def io + @io ||= MultiIO.new( + StringIO.new(@head), + @file, + StringIO.new(@foot) + ) + end + + def size + @head.bytesize + @file.size + @foot.bytesize + end + + private + + def build(name, file) + filename = File.basename(file.path) + mime_type = MIME::Types.type_for(filename)[0] || MIME::Types['application/octet-stream'][0] + + part = %|--#{BOUNDARY}\r\n| + part << %|Content-Disposition: form-data; name="#{CGI.escape(name)}"; filename="#{filename}"\r\n| + part << %|Content-Length: #{file.size}\r\n| + part << %|Content-Type: #{mime_type.simplified}| + part << %|Content-Transfer-Encoding: binary\r\n| + part << %|\r\n| + part + end + end + + # + # The end of the entire request + # + class EndingPart + def initialize + @part = "--#{BOUNDARY}--\r\n\r\n" + end + + def io + @io ||= StringIO.new(@part) + end + + def size + @part.bytesize end end end end