From d4dc24f4024a10c3d75a3fcc570398f476db5581 Mon Sep 17 00:00:00 2001 From: Andy Jeffries Date: Wed, 18 Sep 2019 15:43:28 +0100 Subject: [PATCH] Add form_multipart for file uploads --- CHANGELOG.md | 6 +++ docs/body-types.md | 2 + lib/flexirest.rb | 1 + lib/flexirest/multipart.rb | 81 ++++++++++++++++++++++++++++++++++++++ lib/flexirest/request.rb | 19 +++++++++ lib/flexirest/version.rb | 2 +- spec/lib/request_spec.rb | 22 +++++++++++ spec/samples/file.txt | 1 + 8 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 lib/flexirest/multipart.rb create mode 100644 spec/samples/file.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2331f4d..edab3db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.8.2 + +Features: + +- Allowed use of `:form_multipart` as a `request_body_type` to allow uploading files to an API. + ## 1.8.1 Features: diff --git a/docs/body-types.md b/docs/body-types.md index ce140a7..a798ea7 100644 --- a/docs/body-types.md +++ b/docs/body-types.md @@ -17,6 +17,8 @@ Flexirest::Base.request_body_type = :json This will also set the header `Content-Type` to `application/x-www-form-urlencoded` by default or `application/json; charset=utf-8` when `:json`. You can override this using the callback `before_request`. +You can also use `:form_multipart` if your API requires file uploads. Any parameters set to `File` like object (supporting `#path` and `#read`) will be automatically uploaded with the parameters, in a normal form submission style. + If you have an API that is inconsistent in its body type requirements, you can also specify it on the individual method mapping: ```ruby diff --git a/lib/flexirest.rb b/lib/flexirest.rb index a4ef676..813332c 100644 --- a/lib/flexirest.rb +++ b/lib/flexirest.rb @@ -13,6 +13,7 @@ require "flexirest/headers_list" require "flexirest/lazy_loader" require "flexirest/lazy_association_loader" +require "flexirest/multipart" require "flexirest/json_api_proxy" require "flexirest/request" require "flexirest/request_delegator" diff --git a/lib/flexirest/multipart.rb b/lib/flexirest/multipart.rb new file mode 100644 index 0000000..18e9af2 --- /dev/null +++ b/lib/flexirest/multipart.rb @@ -0,0 +1,81 @@ +# Takes a hash of string and file parameters and returns a string of text +# formatted to be sent as a multipart form post. +# +# Author:: Cody Brimhall +# Created:: 22 Feb 2008 +# License:: Distributed under the terms of the WTFPL (http://www.wtfpl.net/txt/copying/) + +require 'rubygems' +require 'mime/types' +require 'cgi' + + +module Flexirest + module Multipart + VERSION = "1.0.0" + + # Formats a given hash as a multipart form post + # If a hash value responds to :string or :read messages, then it is + # interpreted as a file and processed accordingly; otherwise, it is assumed + # to be a string + class Post + BOUNDARY = "FLEXIRESTBOUNDARY-20190918-FLEXIRESTBOUNDARY" + CONTENT_TYPE = "multipart/form-data; boundary=#{BOUNDARY}" + HEADER = {"Content-Type" => CONTENT_TYPE} + + def self.prepare_query(params) + fp = [] + + params.stringify_keys.each do |k, v| + # Are we trying to make a file parameter? + if v.respond_to?(:path) and v.respond_to?(:read) then + fp.push(FileParam.new(k, v.path, v.read)) + # We must be trying to make a regular parameter + else + fp.push(StringParam.new(k, v)) + end + end + + # Assemble the request body using the special multipart format + query = fp.collect {|p| "--" + BOUNDARY + "\r\n" + p.to_multipart }.join("") + "--" + BOUNDARY + "--" + return query, HEADER + end + end + + private + + # Formats a basic string key/value pair for inclusion with a multipart post + class StringParam + attr_accessor :k, :v + + def initialize(k, v) + @k = k + @v = v + end + + def to_multipart + return "Content-Disposition: form-data; name=\"#{CGI::escape(k)}\"\r\n\r\n#{v}\r\n" + end + end + + # Formats the contents of a file or string for inclusion with a multipart + # form post + class FileParam + attr_accessor :k, :filename, :content + + def initialize(k, filename, content) + @k = k + @filename = filename + @content = content + end + + def to_multipart + # If we can tell the possible mime-type from the filename, use the + # first in the list; otherwise, use "application/octet-stream" + mime_type = MIME::Types.type_for(filename)[0] || MIME::Types["application/octet-stream"][0] + return "Content-Disposition: form-data; name=\"#{CGI::escape(k)}\"; filename=\"#{ filename }\"\r\n" + + "Content-Type: #{ mime_type.simplified }\r\n\r\n#{ content }\r\n" + end + end + end +end diff --git a/lib/flexirest/request.rb b/lib/flexirest/request.rb index e4ab1b6..0e6ef6e 100644 --- a/lib/flexirest/request.rb +++ b/lib/flexirest/request.rb @@ -400,6 +400,8 @@ def prepare_request_body(params = nil) elsif http_method == :get || (http_method == :delete && !@method[:options][:send_delete_body]) if request_body_type == :form_encoded headers["Content-Type"] ||= "application/x-www-form-urlencoded; charset=utf-8" + elsif request_body_type == :form_multipart + headers["Content-Type"] ||= "multipart/form-data; charset=utf-8" elsif request_body_type == :json headers["Content-Type"] ||= "application/json; charset=utf-8" end @@ -417,6 +419,23 @@ def prepare_request_body(params = nil) p.to_query end headers["Content-Type"] ||= "application/x-www-form-urlencoded" + elsif request_body_type == :form_multipart + headers["Content-Type"] ||= "multipart/form-data; charset=utf-8" + @body ||= if params.is_a?(String) + params + elsif @post_params.is_a?(String) + @post_params + else + p = (params || @post_params || {}) + if @method[:options][:wrap_root].present? + p = {@method[:options][:wrap_root] => p} + end + data, mp_headers = Flexirest::Multipart::Post.prepare_query(p) + mp_headers.each do |k,v| + headers[k] = v + end + data + end elsif request_body_type == :json @body ||= if params.is_a?(String) params diff --git a/lib/flexirest/version.rb b/lib/flexirest/version.rb index 9c0c83b..af68b7d 100644 --- a/lib/flexirest/version.rb +++ b/lib/flexirest/version.rb @@ -1,3 +1,3 @@ module Flexirest - VERSION = "1.8.1" + VERSION = "1.8.2" end diff --git a/spec/lib/request_spec.rb b/spec/lib/request_spec.rb index 9870b9c..447ec4f 100644 --- a/spec/lib/request_spec.rb +++ b/spec/lib/request_spec.rb @@ -353,6 +353,28 @@ class WhitelistedDateClient < Flexirest::Base ExampleClient.update id:1234, debug:true, test:'foo' end + it "should encode the body in a form-encoded format by default" do + body = "--FLEXIRESTBOUNDARY-20190918-FLEXIRESTBOUNDARY\r\n" + + "Content-Disposition: form-data; name=\"debug\"\r\n" + + "\r\n" + + "true\r\n" + + "--FLEXIRESTBOUNDARY-20190918-FLEXIRESTBOUNDARY\r\n" + + "Content-Disposition: form-data; name=\"test\"\r\n" + + "\r\n" + + "foo\r\n" + + "--FLEXIRESTBOUNDARY-20190918-FLEXIRESTBOUNDARY\r\n" + + "Content-Disposition: form-data; name=\"file\"; filename=\"#{File.dirname(__FILE__)}/../../spec/samples/file.txt\"\r\n" + + "Content-Type: text/plain\r\n" + + "\r\n" + + "The quick brown fox jumps over the lazy dog\n\r\n" + + "--FLEXIRESTBOUNDARY-20190918-FLEXIRESTBOUNDARY--" + expect_any_instance_of(Flexirest::Connection).to receive(:put).with( + "/put/1234", body, hash_including(headers: hash_including("Content-Type"=>"multipart/form-data; boundary=FLEXIRESTBOUNDARY-20190918-FLEXIRESTBOUNDARY")) + ).and_return(::FaradayResponseMock.new(OpenStruct.new(body:"{\"result\":true}", response_headers:{}))) + ExampleClient.request_body_type :form_multipart + ExampleClient.update id:1234, debug:true, test:'foo', file: File.open("#{File.dirname(__FILE__)}/../../spec/samples/file.txt") + end + it "should encode the body in a JSON format if specified" do expect_any_instance_of(Flexirest::Connection).to receive(:put).with("/put/1234", %q({"debug":true,"test":"foo"}), an_instance_of(Hash)).and_return(::FaradayResponseMock.new(OpenStruct.new(body:"{\"result\":true}", response_headers:{}))) ExampleClient.request_body_type :json diff --git a/spec/samples/file.txt b/spec/samples/file.txt new file mode 100644 index 0000000..84102df --- /dev/null +++ b/spec/samples/file.txt @@ -0,0 +1 @@ +The quick brown fox jumps over the lazy dog