Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
grac (4.4.1)
grac (4.5.0)
logger
oj (~> 3.16)
typhoeus (~> 1)
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,37 @@ 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 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(
"http://localhost:80",
headers: { "Content-Type" => "multipart/form-data" }
)

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`:

```ruby
client = Grac::Client.new("http://localhost:80")

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

* 3xx status codes (i.e. redirects) are not yet supported.
Expand Down
24 changes: 20 additions & 4 deletions lib/grac/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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]

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 == 'Content-Type' }
end

end
end
2 changes: 1 addition & 1 deletion lib/grac/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

module Grac

VERSION = '4.4.1'
VERSION = '4.5.0'

end
110 changes: 110 additions & 0 deletions spec/lib/grac/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,102 @@ 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

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
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)
Expand Down Expand Up @@ -568,6 +664,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'
Expand Down