Software Developer

Access Request Headers in a Rails Controller

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

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

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"}

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

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]

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

delegate :headers, :status=, :location=, :content_type=,
         :status, :location, :content_type, :media_type, to: "@_response"

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

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

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

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.