From 33daf43c8d8d9389cee2081e5cf40941b125037a Mon Sep 17 00:00:00 2001 From: Tobias Schoknecht Date: Fri, 13 Feb 2026 13:14:46 +0100 Subject: [PATCH 1/4] Add multipart form data support --- CHANGELOG.md | 4 ++ Gemfile.lock | 2 +- README.md | 27 +++++++++++ lib/grac/client.rb | 24 ++++++++-- lib/grac/version.rb | 2 +- spec/lib/grac/client_spec.rb | 89 ++++++++++++++++++++++++++++++++++++ 6 files changed, 142 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abbb85e..0bf1b8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 4.5.0 + +Add support for file uploads via multipart form data in the Grac client configuration + ## 4.4.1 Add `logger` as runtime dependency for Ruby 4 support diff --git a/Gemfile.lock b/Gemfile.lock index 2785450..66f6c29 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - grac (4.4.1) + grac (4.5.0) logger oj (~> 3.16) typhoeus (~> 1) diff --git a/README.md b/README.md index 8f0e3fc..6222b14 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,33 @@ SSL options are deep-merged when using `set`, allowing you to override individua client_with_cert = client.set(ssl: { cert: "/path/to/other.crt" }) ``` +### Multipart Form Data / File Uploads + +Grac supports uploading files via multipart form data. Set the `Content-Type` header to `multipart/form-data` and pass the file as the request body. Grac will automatically configure Typhoeus for multipart handling, including setting the correct `Content-Type` header with the multipart boundary. + +```ruby +client = Grac::Client.new( + "http://localhost:80", + headers: { "Content-Type" => "multipart/form-data" } +) + +client.path("/v1/documents").post( + file: File.open("/path/to/document.pdf", "rb"), + description: "My document" +) +``` + +You can also set the `Content-Type` on a per-request basis using `set`: + +```ruby +client = Grac::Client.new("http://localhost:80") + +client + .set(headers: { "Content-Type" => "multipart/form-data" }) + .path("/v1/documents") + .post(file: File.open("/path/to/document.pdf", "rb"), description: "My document") +``` + ## Limitations * 3xx status codes (i.e. redirects) are not yet supported. diff --git a/lib/grac/client.rb b/lib/grac/client.rb index c1abaf0..a23c2f3 100644 --- a/lib/grac/client.rb +++ b/lib/grac/client.rb @@ -97,6 +97,7 @@ def call(opts, request_uri, method, params, body) } apply_proxy_options(request_hash, opts[:proxy]) apply_ssl_options(request_hash, opts[:ssl]) + apply_multipart_handling(request_hash, opts[:headers]) request = ::Typhoeus::Request.new(request_uri, request_hash) response = request.run @@ -151,6 +152,9 @@ def prepare_body_by_content_type(body) when /\Aapplication\/x-www-form-urlencoded/ # Typhoeus will take care of the encoding when receiving a hash return body + when /\Amultipart\/form-data/ + # Typhoeus will take care of the encoding when receiving a hash + return body else # Do not encode other unknown Content-Types either. # The default is JSON through the Content-Type header which is set by default. @@ -162,7 +166,7 @@ def middleware_chain callee = self @options[:middleware].reverse.each do |mw| - if mw.kind_of?(Array) + if mw.is_a?(Array) middleware_class = mw[0] params = mw[1..-1] @@ -220,17 +224,17 @@ def check_response(method, response) def postprocessing(data, processing = nil) return data if @options[:postprocessing].nil? || @options[:postprocessing].empty? - if data.kind_of?(Hash) + if data.is_a?(Hash) data.each do |key, value| processing = nil regexp = @options[:postprocessing].keys.detect { |pattern| pattern.match?(key) } - if !regexp.nil? + if regexp != nil processing = @options[:postprocessing][regexp] end data[key] = postprocessing(value, processing) end - elsif data.kind_of?(Array) + elsif data.is_a?(Array) data.each_with_index do |value, index| data[index] = postprocessing(value, processing) end @@ -279,5 +283,17 @@ def apply_ssl_options(request_hash, ssl) request_hash[:cainfo] = ssl[:ca_info] if ssl[:ca_info] != nil end + def apply_multipart_handling(request_hash, headers) + return if headers == nil + return if !headers['Content-Type'] + return if !headers['Content-Type'].include?('multipart/form-data') + + request_hash[:multipart] = true + # Typhoeus does not expect a Content-Type header for multipart requests. + # It sets the correct one itself including the boundary. + # Therefore we need to remove the Content-Type header from the request. + request_hash[:headers] = headers.reject { |k, _| k.downcase == 'content-type' } + end + end end diff --git a/lib/grac/version.rb b/lib/grac/version.rb index c8634d5..7f6ca92 100644 --- a/lib/grac/version.rb +++ b/lib/grac/version.rb @@ -2,6 +2,6 @@ module Grac - VERSION = '4.4.1' + VERSION = '4.5.0' end diff --git a/spec/lib/grac/client_spec.rb b/spec/lib/grac/client_spec.rb index 8928b89..d0c0c18 100644 --- a/spec/lib/grac/client_spec.rb +++ b/spec/lib/grac/client_spec.rb @@ -442,6 +442,81 @@ def check_options(client, field, value) end end + context "#call with multipart content type" do + let(:method) { "post" } + let(:params) { { "param1" => "value" } } + let(:body) { { "file" => "file_data", "name" => "test" } } + let(:request_uri) { "http://example.com" } + + let(:opts) do + { + connecttimeout: 1, + timeout: 3, + headers: { "User-Agent" => "test", "Content-Type" => "multipart/form-data" } + } + end + + let(:request_hash) do + { + method: method, + params: params, + body: body, + connecttimeout: opts[:connecttimeout], + timeout: opts[:timeout], + headers: { "User-Agent" => "test" }, + multipart: true + } + end + + it "sets multipart flag and removes Content-Type header" do + expect(::Typhoeus::Request).to receive(:new) + .with(request_uri, request_hash) + .and_return(request = double('request', url: request_uri)) + expect(request).to receive(:run).and_return(response = double('response', body: body)) + expect(response).to receive(:timed_out?).twice.and_return(false) + expect(response).to receive(:return_code).and_return(:ok) + + grac.call(opts, request_uri, method, params, body) + end + end + + context "#call with non-multipart content type" do + let(:method) { "post" } + let(:params) { { "param1" => "value" } } + let(:body) { "body" } + let(:request_uri) { "http://example.com" } + + let(:opts) do + { + connecttimeout: 1, + timeout: 3, + headers: { "User-Agent" => "test", "Content-Type" => "application/json" } + } + end + + let(:request_hash) do + { + method: method, + params: params, + body: body, + connecttimeout: opts[:connecttimeout], + timeout: opts[:timeout], + headers: opts[:headers] + } + end + + it "does not set multipart flag or modify headers" do + expect(::Typhoeus::Request).to receive(:new) + .with(request_uri, request_hash) + .and_return(request = double('request', url: request_uri)) + expect(request).to receive(:run).and_return(response = double('response', body: body)) + expect(response).to receive(:timed_out?).twice.and_return(false) + expect(response).to receive(:return_code).and_return(:ok) + + grac.call(opts, request_uri, method, params, body) + end + end + context "middleware_chain" do after do caller = @client.send(:middleware_chain) @@ -568,6 +643,20 @@ def check_options(client, field, value) end end + context 'when the content type is multipart/form-data' do + let(:content_type) do + 'multipart/form-data' + end + + let(:prepared_body) do + body + end + + it 'does not specifically encode the body' do + expect(client_call).to eq(1) + end + end + context 'default' do let(:content_type) do 'application/unknown' From b675946560cb012b9582a1bc3e2af3d73a6e5ef4 Mon Sep 17 00:00:00 2001 From: Tobias Schoknecht Date: Fri, 13 Feb 2026 13:25:54 +0100 Subject: [PATCH 2/4] Keep consistency in header handling Since the default header is set with casing, and handled with casing in prepare_body_by_content_type, we should keep it consistent --- lib/grac/client.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/grac/client.rb b/lib/grac/client.rb index a23c2f3..a9390f5 100644 --- a/lib/grac/client.rb +++ b/lib/grac/client.rb @@ -292,7 +292,7 @@ def apply_multipart_handling(request_hash, headers) # Typhoeus does not expect a Content-Type header for multipart requests. # It sets the correct one itself including the boundary. # Therefore we need to remove the Content-Type header from the request. - request_hash[:headers] = headers.reject { |k, _| k.downcase == 'content-type' } + request_hash[:headers] = headers.reject { |k, _| k == 'Content-Type' } end end From 854faa29b3475b830862de7810279f9a2e19e178 Mon Sep 17 00:00:00 2001 From: Tobias Schoknecht Date: Fri, 13 Feb 2026 13:28:11 +0100 Subject: [PATCH 3/4] Improve readme examples --- README.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 6222b14..07e6f3f 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,7 @@ client_with_cert = client.set(ssl: { cert: "/path/to/other.crt" }) ### Multipart Form Data / File Uploads -Grac supports uploading files via multipart form data. Set the `Content-Type` header to `multipart/form-data` and pass the file as the request body. Grac will automatically configure Typhoeus for multipart handling, including setting the correct `Content-Type` header with the multipart boundary. +Grac supports uploading files via multipart form data. Set the `Content-Type` header to `multipart/form-data` and pass a hash of multipart form fields as the request body, with the file IO as one of the values (for example, `file:` plus any additional fields like `description:`). Grac will automatically configure Typhoeus for multipart handling, including setting the correct `Content-Type` header with the multipart boundary. ```ruby client = Grac::Client.new( @@ -339,10 +339,12 @@ client = Grac::Client.new( headers: { "Content-Type" => "multipart/form-data" } ) -client.path("/v1/documents").post( - file: File.open("/path/to/document.pdf", "rb"), - description: "My document" -) +File.open("/path/to/document.pdf", "rb") do |file| + client.path("/v1/documents").post( + file: file, + description: "My document" + ) +end ``` You can also set the `Content-Type` on a per-request basis using `set`: @@ -350,10 +352,12 @@ You can also set the `Content-Type` on a per-request basis using `set`: ```ruby client = Grac::Client.new("http://localhost:80") -client - .set(headers: { "Content-Type" => "multipart/form-data" }) - .path("/v1/documents") - .post(file: File.open("/path/to/document.pdf", "rb"), description: "My document") +File.open("/path/to/document.pdf", "rb") do |file| + client + .set(headers: { "Content-Type" => "multipart/form-data" }) + .path("/v1/documents") + .post(file: file, description: "My document") +end ``` ## Limitations From 6121ed50c9cdab9ca12ba521bf2f0e8978831d8f Mon Sep 17 00:00:00 2001 From: Tobias Schoknecht Date: Fri, 13 Feb 2026 13:28:21 +0100 Subject: [PATCH 4/4] Handle test case for multipart form data with boundary --- spec/lib/grac/client_spec.rb | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/spec/lib/grac/client_spec.rb b/spec/lib/grac/client_spec.rb index d0c0c18..04af671 100644 --- a/spec/lib/grac/client_spec.rb +++ b/spec/lib/grac/client_spec.rb @@ -478,6 +478,27 @@ def check_options(client, field, value) grac.call(opts, request_uri, method, params, body) end + + context "when Content-Type includes a boundary parameter" do + let(:opts) do + { + connecttimeout: 1, + timeout: 3, + headers: { "User-Agent" => "test", "Content-Type" => "multipart/form-data; boundary=----abc123" } + } + end + + it "still sets multipart flag and removes Content-Type header" do + expect(::Typhoeus::Request).to receive(:new) + .with(request_uri, request_hash) + .and_return(request = double('request', url: request_uri)) + expect(request).to receive(:run).and_return(response = double('response', body: body)) + expect(response).to receive(:timed_out?).twice.and_return(false) + expect(response).to receive(:return_code).and_return(:ok) + + grac.call(opts, request_uri, method, params, body) + end + end end context "#call with non-multipart content type" do