Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

Loading…

ActiveResource shouldn't rely on the presence of Content-Length #2678

Merged
merged 2 commits into from

3 participants

@jmileham

Hi,

I recently ran into issues using ActiveResource against another Rails app running out of Pow. Rails doesn't use the ContentLength middleware by default when started from config.ru, so if the rack server (in Pow's case nack) doesn't add a Content-Length header on its own (which nack doesn't, but many servers do), ActiveResource ignores the response body of a save, preventing me from reading the server state (which in the case of creates means you lose track of the new object's ID).

The relevant part of the HTTP 1.1 spec says that the Content-Length header "SHOULD be sent whenever the message's length can be determined prior to being transferred," but SHOULD isn't MUST, and even if it were, it's conceivable that a custom JSON API might start streaming before it has finished counting bytes, so requiring a Content-Length header as evidence that a response body exists seems incorrect.

The current behavior came about in this commit from @malyk, associated with lighthouse ticket #5038. The test case from that thread provided by @neerajdotname still passes, so this patch doesn't alter any tested behavior, and presumably satisfies #5038.

This patch also provides better resilience by ignoring the body of an HTTP response that isn't allowed to have one as another way of preventing parse errors if the server sends a garbage body along with a 204 response.

#5038 didn't make it into 3.0, so this bug only manifests on 3-1-stable and onward. I'd be happy to provide a pull request for 3-1-stable as well.

@guilleiguaran guilleiguaran referenced this pull request from a commit
Commit has since been removed from the repository and is no longer available.
@jmileham

@kaiwren @jnicklas - this patch fixes the issue reported on rails-core this AM. I replied on rails-core but the post hasn't been approved yet.

@jonleighton
Collaborator

Thanks for the pull request.

I realise you're just copying format of the existing tests, but calling a private method via send is quite brittle. Could you look into stubbing the call to connection.post or connection.put instead?

@jmileham

@jonleighton will do. IIRC I took a crack at this and things looked a bit nasty (and thought that since I was just copying existing style, as you say, I could get away with it), but I'll give it another go.

@jmileham

@jonleighton - is this more of what you were looking for? If so should I force push this version to #2678 or open a new pull request? If not, any other notes?

@jmileham

force pushed revised tests to keep the conversation together.

@jonleighton jonleighton commented on the diff
activeresource/test/cases/base_test.rb
((7 lines not shown))
resp = ActiveResource::Response.new(nil)
resp['Content-Length'] = "100"
- assert_nil p.__send__(:load_attributes_from_response, resp)
+ Person.connection.expects(:post).returns(resp)
+ assert !Person.create.persisted?
+ end
+
+ def test_not_persisted_with_body_and_zero_content_length
+ resp = ActiveResource::Response.new(@rick)
+ resp['Content-Length'] = "0"
+ Person.connection.expects(:post).returns(resp)
@jonleighton Collaborator

great, yeah this was what i'm after, but use stubs(:post) rather than expects because we are not testing whether Post.connection.post gets called in this test, just stubbing out the response. (also, force pushing is good, keeps the conversation in one place)

I had originally written it to use stubs() but I was concerned that since we're only asserting a boolean in each test, if somebody changes the implementation of ARes.create not to ultimatelly call Ares.connection.post, these tests will no longer be exercising the expected logic, and only some of them will fail, leaving effectively no-op tests behind that would no longer protect against a regression. Using expects should ensure that the tests get refactored if/when they need to, but shouldn't be more brittle than necessary. What do you think?

@jonleighton Collaborator

But if ARes.create was changed to not call Ares.connection.post, at least one of these tests would end up failing, surely?

At least one, yup. Just not all. I just didn't want to put the onus on somebody working on something else to find adjacent passing tests and fix them as well.

@jonleighton Collaborator

I guess. Alright then, I'll merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
activeresource/lib/active_resource/base.rb
@@ -1381,6 +1381,19 @@ module ActiveResource
end
private
+
+ def response_code_allows_body?(c)
+ !((100..199).include?(c) || [204,304].include?(c))
+ end
+
+ def content_length_nonzero_or_unset?(response)
+ if response['Content-Length']
+ response['Content-Length'] != "0"
+ else
+ true
+ end
+ end
@dasch
dasch added a note

How about response['Content-Length'].nil? || response['Content-Length'] != "0"?

Good point -- not the tidiest code ever. I'll probably throw this back into the calling method actually.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@jonleighton jonleighton commented on the diff
activeresource/lib/active_resource/base.rb
@@ -1357,7 +1357,9 @@ module ActiveResource
end
def load_attributes_from_response(response)
- if !response['Content-Length'].blank? && response['Content-Length'] != "0" && !response.body.nil? && response.body.strip.size > 0
+ if (response_code_allows_body?(response.code) &&
+ (response['Content-Length'].nil? || response['Content-Length'] != "0") &&
+ !response.body.nil? && response.body.strip.size > 0)
@jonleighton Collaborator

Hey, one more thing... how about having a body_present? method, like this:

def body_present?(response)
  !(100..199).include?(response.code) && ![204,304].include?(response.code) &&
  (response['Content-Length'].nil? || response['Content-Length'] != "0") &&
  response.body &&
  response.body.strip.size > 0
end

I am going to commit now anyway so we can get this fix in before the 3.1.1 RC, but if you fancy refactoring in master in a new PR you are welcome to...

Yeah, this does seem like the right way to go. I was edgy about how long that line was but didn't want to throw away the lazy evaluation win that &&'ing it all together brought. I'll send you a pull request this week. Thanks a lot!

@jonleighton Collaborator

In fact, you could look at putting the body_present? method inside the ActiveResource::Response and then just call if response.body_present?.

Yeah, sounds great.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@jonleighton jonleighton merged commit 8397a56 into rails:master
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
This page is out of date. Refresh to see the latest.
View
10 activeresource/lib/active_resource/base.rb
@@ -1357,7 +1357,9 @@ def create
end
def load_attributes_from_response(response)
- if !response['Content-Length'].blank? && response['Content-Length'] != "0" && !response.body.nil? && response.body.strip.size > 0
+ if (response_code_allows_body?(response.code) &&
+ (response['Content-Length'].nil? || response['Content-Length'] != "0") &&
+ !response.body.nil? && response.body.strip.size > 0)
@jonleighton Collaborator

Hey, one more thing... how about having a body_present? method, like this:

def body_present?(response)
  !(100..199).include?(response.code) && ![204,304].include?(response.code) &&
  (response['Content-Length'].nil? || response['Content-Length'] != "0") &&
  response.body &&
  response.body.strip.size > 0
end

I am going to commit now anyway so we can get this fix in before the 3.1.1 RC, but if you fancy refactoring in master in a new PR you are welcome to...

Yeah, this does seem like the right way to go. I was edgy about how long that line was but didn't want to throw away the lazy evaluation win that &&'ing it all together brought. I'll send you a pull request this week. Thanks a lot!

@jonleighton Collaborator

In fact, you could look at putting the body_present? method inside the ActiveResource::Response and then just call if response.body_present?.

Yeah, sounds great.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
load(self.class.format.decode(response.body), true)
@persisted = true
end
@@ -1381,6 +1383,12 @@ def collection_path(options = nil)
end
private
+
+ # Determine whether the response is allowed to have a body per HTTP 1.1 spec section 4.4.1
+ def response_code_allows_body?(c)
+ !((100..199).include?(c) || [204,304].include?(c))
+ end
+
# Tries to find a resource for a given collection name; if it fails, then the resource is created
def find_or_create_resource_for_collection(name)
find_or_create_resource_for(ActiveSupport::Inflector.singularize(name.to_s))
View
30 activeresource/test/cases/base_test.rb
@@ -636,13 +636,37 @@ def test_id_from_response_without_location
assert_nil p.__send__(:id_from_response, resp)
end
- def test_load_attributes_from_response
- p = Person.new
+ def test_not_persisted_with_no_body_and_positive_content_length
resp = ActiveResource::Response.new(nil)
resp['Content-Length'] = "100"
- assert_nil p.__send__(:load_attributes_from_response, resp)
+ Person.connection.expects(:post).returns(resp)
+ assert !Person.create.persisted?
+ end
+
+ def test_not_persisted_with_body_and_zero_content_length
+ resp = ActiveResource::Response.new(@rick)
+ resp['Content-Length'] = "0"
+ Person.connection.expects(:post).returns(resp)
@jonleighton Collaborator

great, yeah this was what i'm after, but use stubs(:post) rather than expects because we are not testing whether Post.connection.post gets called in this test, just stubbing out the response. (also, force pushing is good, keeps the conversation in one place)

I had originally written it to use stubs() but I was concerned that since we're only asserting a boolean in each test, if somebody changes the implementation of ARes.create not to ultimatelly call Ares.connection.post, these tests will no longer be exercising the expected logic, and only some of them will fail, leaving effectively no-op tests behind that would no longer protect against a regression. Using expects should ensure that the tests get refactored if/when they need to, but shouldn't be more brittle than necessary. What do you think?

@jonleighton Collaborator

But if ARes.create was changed to not call Ares.connection.post, at least one of these tests would end up failing, surely?

At least one, yup. Just not all. I just didn't want to put the onus on somebody working on something else to find adjacent passing tests and fix them as well.

@jonleighton Collaborator

I guess. Alright then, I'll merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
+ assert !Person.create.persisted?
end
+ # These response codes aren't allowed to have bodies per HTTP spec
+ def test_not_persisted_with_empty_response_codes
+ [100,101,204,304].each do |status_code|
+ resp = ActiveResource::Response.new(@rick, status_code)
+ Person.connection.expects(:post).returns(resp)
+ assert !Person.create.persisted?
+ end
+ end
+
+ # Content-Length is not required by HTTP 1.1, so we should read
+ # the body anyway in its absence.
+ def test_persisted_with_no_content_length
+ resp = ActiveResource::Response.new(@rick)
+ resp['Content-Length'] = nil
+ Person.connection.expects(:post).returns(resp)
+ assert Person.create.persisted?
+ end
def test_create_with_custom_prefix
matzs_house = StreetAddress.new(:person_id => 1)
Something went wrong with that request. Please try again.