Access Request Headers in a Rails Controller

Kevin Murphy - Jul 16 - - Dev Community

Heads Up

A coworker presented a failing request spec. They asked if they were passing headers incorrectly in the test.

it "reports to be a teapot when asked to brew coffee" do
  headers = { "X-COMMAND" => "brew coffee" }
  get drinks_url, headers: headers

  expect(response.status).to eq 418
end
Enter fullscreen mode Exit fullscreen mode

They wrote the test exactly like I'd expect. But, rather than providing the 418, a 200 OK was the status code. I then looked at the controller this request spec was accessing.

def index
  if headers["X-COMMAND"] == "brew coffee"
    head 418
  end

  @drinks = Drink.all
end
Enter fullscreen mode Exit fullscreen mode

Nothing obvious caught my attention. But now that I'd been effectively nerd sniped, I had to figure out what was going on.

Heading In For a Closer Look

I added a breakpoint inside the controller to inspect the headers when the test was running.

irb:001:0> headers
=>
{"X-Frame-Options"=>"SAMEORIGIN",
 "X-XSS-Protection"=>"0",
 "X-Content-Type-Options"=>"nosniff",
 "X-Download-Options"=>"noopen",
 "X-Permitted-Cross-Domain-Policies"=>"none",
 "Referrer-Policy"=>"strict-origin-when-cross-origin"}
Enter fullscreen mode Exit fullscreen mode

As expected, given the failing test, the X-COMMAND header was nowhere to be found. But luckily, they did seem familiar to me. They looked to be Rails' default headers. But those default headers are for the response, not the request.

I still had my console session with my breakpoint, so I asked what kind of headers we were interacting with.

irb:002:0> headers.class
=> ActionDispatch::Response::Header
Enter fullscreen mode Exit fullscreen mode

This confirmed we were dealing with the response, not request, headers.

Heads Down

I needed to trace my way backwards from what I have or know. I asked what defines the headers method by asking for the source location. That'll tell me the file and line number.

irb:003:0> method("headers").source_location
=> [".../gems/actionpack-7.0.3.1/lib/action_controller/metal.rb", 147]
Enter fullscreen mode Exit fullscreen mode

That line shows headers delegated to an internal attribute @_response.

delegate :headers, :status=, :location=, :content_type=,
         :status, :location, :content_type, :media_type, to: "@_response"
Enter fullscreen mode Exit fullscreen mode

That internal attribute is accessible in the controller by calling response. We can see that from the attr_internal definition on line 145.

attr_internal :response, :request
Enter fullscreen mode Exit fullscreen mode

response isn't the ONLY internal attribute on that line though. There's ALSO a request. In our console, let's see what that request is.

irb:004:0> request.class
=> ActionDispatch::Request
Enter fullscreen mode Exit fullscreen mode

That class also responds to headers, providing the request headers.

Heading In For The Close

The change to get our test to pass is small. We don't want the response headers, which is what the headers variable is. We need the request headers, which are accessible at request.headers.

def index
  if request.headers["X-COMMAND"] == "brew coffee"
    head 418
  end

  @drinks = Drink.all
end
Enter fullscreen mode Exit fullscreen mode

Now that we're accessing the headers of the request our test passes.

Naming is hard. Asking for a controller's headers could be either the request or the response headers. Turns out, Rails will give you the response headers. To access the request headers, explicitly ask for them from the request object.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .