Skip to content

Commit

Permalink
support multipart put and patch requests
Browse files Browse the repository at this point in the history
  • Loading branch information
taf2 committed Feb 10, 2025
1 parent 4450ee0 commit 1618ec3
Show file tree
Hide file tree
Showing 4 changed files with 230 additions and 22 deletions.
2 changes: 2 additions & 0 deletions ChangeLog.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# Change Log
## 1.0.8
* PUT and PATCH requests now behave like POST requests with multipart form data supporting http_post, http_put, and http_patch with PostField arguments
## 1.0.7
* Clean up easy handle after failing to add to a multi handle
* With newer versions of libcurl we can use curl_easy_escape instead of curl_escape, this should improve character encoding support
Expand Down
145 changes: 140 additions & 5 deletions ext/curb_easy.c
Original file line number Diff line number Diff line change
Expand Up @@ -2758,6 +2758,88 @@ static VALUE ruby_curl_easy_perform_post(int argc, VALUE *argv, VALUE self) {
}
}

/*
* call-seq:
* easy.http_patch("url=encoded%20form%20data;and=so%20on") => true
* easy.http_patch("url=encoded%20form%20data", "and=so%20on", ...) => true
* easy.http_patch("url=encoded%20form%20data", Curl::PostField, "and=so%20on", ...) => true
* easy.http_patch(Curl::PostField, Curl::PostField ..., Curl::PostField) => true
*
* PATCH the specified formdata to the currently configured URL using
* the current options set for this Curl::Easy instance. This method
* always returns true, or raises an exception (defined under
* Curl::Err) on error.
*
* When multipart_form_post is true the multipart logic is used; otherwise,
* the arguments are joined into a raw PATCH body.
*/
static VALUE ruby_curl_easy_perform_patch(int argc, VALUE *argv, VALUE self) {
ruby_curl_easy *rbce;
CURL *curl;
int i;
VALUE args_ary;

rb_scan_args(argc, argv, "*", &args_ary);
Data_Get_Struct(self, ruby_curl_easy, rbce);
curl = rbce->curl;

/* Clear the error buffer */
memset(rbce->err_buf, 0, CURL_ERROR_SIZE);

/* Set the custom HTTP method to PATCH */
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PATCH");

if (rbce->multipart_form_post) {
VALUE ret;
struct curl_httppost *first = NULL, *last = NULL;

/* Build the multipart form (same logic as for POST) */
for (i = 0; i < argc; i++) {
if (rb_obj_is_instance_of(argv[i], cCurlPostField)) {
append_to_form(argv[i], &first, &last);
} else if (rb_type(argv[i]) == T_ARRAY) {
long j, argv_len = RARRAY_LEN(argv[i]);
for (j = 0; j < argv_len; ++j) {
if (rb_obj_is_instance_of(rb_ary_entry(argv[i], j), cCurlPostField)) {
append_to_form(rb_ary_entry(argv[i], j), &first, &last);
} else {
rb_raise(eCurlErrInvalidPostField,
"You must use PostFields only with multipart form posts");
return Qnil;
}
}
} else {
rb_raise(eCurlErrInvalidPostField,
"You must use PostFields only with multipart form posts");
return Qnil;
}
}
/* Disable the POST flag */
curl_easy_setopt(curl, CURLOPT_POST, 0);
/* Use the built multipart form as the request body */
curl_easy_setopt(curl, CURLOPT_HTTPPOST, first);
ret = rb_funcall(self, rb_intern("perform"), 0);
curl_formfree(first);
return ret;
} else {
/* Join arguments into a raw PATCH body */
VALUE patch_body = rb_funcall(args_ary, idJoin, 1, rbstrAmp);
if (patch_body == Qnil) {
rb_raise(eCurlErrError, "Failed to join arguments");
return Qnil;
} else {
if (rb_type(patch_body) == T_STRING && RSTRING_LEN(patch_body) > 0) {
ruby_curl_easy_post_body_set(self, patch_body);
}
/* If postdata_buffer is still nil, set it so that the PATCH header is enabled */
if (rb_easy_nil("postdata_buffer")) {
ruby_curl_easy_post_body_set(self, patch_body);
}
return rb_funcall(self, rb_intern("perform"), 0);
}
}
}

/*
* call-seq:
* easy.http_put(data) => true
Expand All @@ -2766,18 +2848,70 @@ static VALUE ruby_curl_easy_perform_post(int argc, VALUE *argv, VALUE self) {
* current options set for this Curl::Easy instance. This method always
* returns true, or raises an exception (defined under Curl::Err) on error.
*/
static VALUE ruby_curl_easy_perform_put(VALUE self, VALUE data) {
static VALUE ruby_curl_easy_perform_put(int argc, VALUE *argv, VALUE self) {
ruby_curl_easy *rbce;
CURL *curl;
VALUE args_ary;
int i;

rb_scan_args(argc, argv, "*", &args_ary);
Data_Get_Struct(self, ruby_curl_easy, rbce);
curl = rbce->curl;

memset(rbce->err_buf, 0, CURL_ERROR_SIZE);
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PUT");

curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, NULL);
ruby_curl_easy_put_data_set(self, data);

/* New: if no arguments were provided, treat as an empty PUT */
if (RARRAY_LEN(args_ary) == 0) {
/* Option 1: explicitly set an empty body */
ruby_curl_easy_put_data_set(self, rb_str_new2(""));
}
/* If a single argument is given and it is a String or responds to read, use legacy behavior */
else if (RARRAY_LEN(args_ary) == 1 &&
(rb_type(rb_ary_entry(args_ary, 0)) == T_STRING ||
rb_respond_to(rb_ary_entry(args_ary, 0), rb_intern("read")))) {
ruby_curl_easy_put_data_set(self, rb_ary_entry(args_ary, 0));
}
/* Otherwise, if multipart_form_post is true, use multipart logic */
else if (rbce->multipart_form_post) {
VALUE ret;
struct curl_httppost *first = NULL, *last = NULL;
for (i = 0; i < RARRAY_LEN(args_ary); i++) {
VALUE field = rb_ary_entry(args_ary, i);
if (rb_obj_is_instance_of(field, cCurlPostField)) {
append_to_form(field, &first, &last);
} else if (rb_type(field) == T_ARRAY) {
long j;
for (j = 0; j < RARRAY_LEN(field); j++) {
VALUE subfield = rb_ary_entry(field, j);
if (rb_obj_is_instance_of(subfield, cCurlPostField)) {
append_to_form(subfield, &first, &last);
} else {
rb_raise(eCurlErrInvalidPostField,
"You must use PostFields only with multipart form posts");
}
}
} else {
rb_raise(eCurlErrInvalidPostField,
"You must use PostFields only with multipart form posts");
}
}
curl_easy_setopt(curl, CURLOPT_POST, 0);
curl_easy_setopt(curl, CURLOPT_HTTPPOST, first);
/* Set the method explicitly to PUT */
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PUT");
ret = rb_funcall(self, rb_intern("perform"), 0);
curl_formfree(first);
return ret;
}
/* Fallback: join all arguments */
else {
VALUE post_body = rb_funcall(args_ary, idJoin, 1, rbstrAmp);
if (post_body != Qnil && rb_type(post_body) == T_STRING &&
RSTRING_LEN(post_body) > 0) {
ruby_curl_easy_put_data_set(self, post_body);
}
}
return rb_funcall(self, rb_intern("perform"), 0);
}

Expand Down Expand Up @@ -3946,7 +4080,8 @@ void init_curb_easy() {

rb_define_method(cCurlEasy, "http", ruby_curl_easy_perform_verb, 1);
rb_define_method(cCurlEasy, "http_post", ruby_curl_easy_perform_post, -1);
rb_define_method(cCurlEasy, "http_put", ruby_curl_easy_perform_put, 1);
rb_define_method(cCurlEasy, "http_put", ruby_curl_easy_perform_put, -1);
rb_define_method(cCurlEasy, "http_patch", ruby_curl_easy_perform_patch, -1);

/* Post-perform info methods */
rb_define_method(cCurlEasy, "body_str", ruby_curl_easy_body_str_get, 0);
Expand Down
29 changes: 26 additions & 3 deletions lib/curl/easy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -374,13 +374,36 @@ def http_head(*args)
#
# call-seq:
# Curl::Easy.http_put(url, data) {|c| ... }
# Curl::Easy.http_put(url, "some=urlencoded%20form%20data&and=so%20on") => true
# Curl::Easy.http_put(url, "some=urlencoded%20form%20data", "and=so%20on", ...) => true
# Curl::Easy.http_put(url, "some=urlencoded%20form%20data", Curl::PostField, "and=so%20on", ...) => true
# Curl::Easy.http_put(url, Curl::PostField, Curl::PostField ..., Curl::PostField) => true
#
# see easy.http_put
#
def http_put(url, data)
c = Curl::Easy.new url
def http_put(*args)
url = args.shift
c = Curl::Easy.new(url)
yield c if block_given?
c.http_put(*args)
c
end

#
# call-seq:
# Curl::Easy.http_patch(url, data) {|c| ... }
# Curl::Easy.http_patch(url, "some=urlencoded%20form%20data&and=so%20on") => true
# Curl::Easy.http_patch(url, "some=urlencoded%20form%20data", "and=so%20on", ...) => true
# Curl::Easy.http_patch(url, "some=urlencoded%20form%20data", Curl::PostField, "and=so%20on", ...) => true
# Curl::Easy.http_patch(url, Curl::PostField, Curl::PostField ..., Curl::PostField) => true
#
# see easy.http_patch
#
def http_patch(*args)
url = args.shift
c = Curl::Easy.new(url)
yield c if block_given?
c.http_put data
c.http_patch(*args)
c
end

Expand Down
76 changes: 62 additions & 14 deletions tests/tc_curl_easy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -765,20 +765,27 @@ def test_get_remote
assert_equal 'GET', curl.body_str
end

def test_post_remote
def test_remote
curl = Curl::Easy.new(TestServlet.url)
curl.http_post([Curl::PostField.content('document_id', 5)])
assert_equal "POST\ndocument_id=5", curl.unescape(curl.body_str)

curl.http_put([Curl::PostField.content('document_id', 5)])
assert_equal "PUT\ndocument_id=5", curl.unescape(curl.body_str)

curl.http_patch([Curl::PostField.content('document_id', 5)])
assert_equal "PATCH\ndocument_id=5", curl.unescape(curl.body_str)
end

def test_post_remote_is_easy_handle
def test_remote_is_easy_handle
# see: http://pastie.org/560852 and
# http://groups.google.com/group/curb---ruby-libcurl-bindings/browse_thread/thread/216bb2d9b037f347?hl=en
[:post, :get, :head, :delete].each do |method|
[:post, :patch, :put, :get, :head, :delete].each do |method|
retries = 0
begin
count = 0
Curl::Easy.send("http_#{method}", TestServlet.url) do|c|
m = "http_#{method}".to_sym
Curl::Easy.send(m, TestServlet.url) do|c|
count += 1
assert_equal Curl::Easy, c.class
end
Expand All @@ -787,12 +794,14 @@ def test_post_remote_is_easy_handle
retries+=1
retry if retries < 3
raise e
rescue ArgumentError => e
assert false, "#{m} - #{e.message}"
end
end
end

# see: https://github.com/rvanlieshout/curb/commit/8bcdefddc0162484681ebd1a92d52a642666a445
def test_post_multipart_array_remote
def test_multipart_array_remote
curl = Curl::Easy.new(TestServlet.url)
curl.multipart_form_post = true
fields = [
Expand All @@ -802,33 +811,66 @@ def test_post_multipart_array_remote
curl.http_post(fields)
assert_match(/HTTP POST file upload/, curl.body_str)
assert_match(/Content-Disposition: form-data/, curl.body_str)

curl = Curl::Easy.new(TestServlet.url)
curl.multipart_form_post = true
fields = [
Curl::PostField.file('foo', File.expand_path(File.join(File.dirname(__FILE__),'..','README.markdown'))),
Curl::PostField.file('bar', File.expand_path(File.join(File.dirname(__FILE__),'..','README.markdown')))
]
curl.http_put(fields)
assert_match(/HTTP POST file upload/, curl.body_str)
assert_match(/Content-Disposition: form-data/, curl.body_str)

curl.http_patch(fields)
assert_match(/HTTP POST file upload/, curl.body_str)
assert_match(/Content-Disposition: form-data/, curl.body_str)
end

def test_post_with_body_remote
def test_with_body_remote
curl = Curl::Easy.new(TestServlet.url)
curl.post_body = 'foo=bar&encoded%20string=val'

curl.perform

assert_equal "POST\nfoo=bar&encoded%20string=val", curl.body_str
assert_equal 'foo=bar&encoded%20string=val', curl.post_body

curl = Curl::Easy.new(TestServlet.url)
curl.put_data = 'foo=bar&encoded%20string=val'

curl.perform

assert_equal "PUT\nfoo=bar&encoded%20string=val", curl.body_str
end

def test_form_post_body_remote
def test_form_body_remote
curl = Curl::Easy.new(TestServlet.url)
curl.http_post('foo=bar', 'encoded%20string=val')

assert_equal "POST\nfoo=bar&encoded%20string=val", curl.body_str
assert_equal 'foo=bar&encoded%20string=val', curl.post_body
end

def test_post_multipart_file_remote
curl = Curl::Easy.new(TestServlet.url)
curl.multipart_form_post = true
pf = Curl::PostField.file('readme', File.expand_path(File.join(File.dirname(__FILE__),'..','README.markdown')))
curl.http_post(pf)
assert_match(/HTTP POST file upload/, curl.body_str)
assert_match(/Content-Disposition: form-data/, curl.body_str)
curl.http_put('foo=bar', 'encoded%20string=val')

assert_equal "PUT\nfoo=bar&encoded%20string=val", curl.body_str

curl = Curl::Easy.new(TestServlet.url)
curl.http_patch('foo=bar', 'encoded%20string=val')

assert_equal "PATCH\nfoo=bar&encoded%20string=val", curl.body_str
end

def test_multipart_file_remote
[:put, :post, :patch].each {|method|
curl = Curl::Easy.new(TestServlet.url)
curl.multipart_form_post = true
pf = Curl::PostField.file('readme', File.expand_path(File.join(File.dirname(__FILE__),'..','README.markdown')))
curl.send("http_#{method}", pf)
assert_match(/HTTP POST file upload/, curl.body_str)
assert_match(/Content-Disposition: form-data/, curl.body_str)
}
end

def test_delete_remote
Expand Down Expand Up @@ -1065,6 +1107,12 @@ def test_easy_http_verbs
assert_equal "PUT\nhello", curl.body_str
curl.http('COPY')
assert_equal 'COPY', curl.body_str

curl.http_patch
assert_equal "PATCH\n", curl.body_str

curl.http_put
assert_equal "PUT\n", curl.body_str
end

def test_easy_http_verbs_must_respond_to_str
Expand Down

0 comments on commit 1618ec3

Please sign in to comment.