request.format is not your friend

2024-03-20 - blog

How many times you wanted to have your endpoint exclusively receive requests in JSON format?

I bet probably wrote:

head :unprocessable_entity unless request.format.json?

and thought: My job here is done!. Well not so fast…

What you probably meant is:

If the request is not application/json, then reject it with 422 Unprocessable Entity.

Like any human being who wants to use HTTP codes appropriately. So you do the rational thing: put it to work

# I'm exaggerating my curl profficiency just to prove a point

curl -sSL -w "%{http_code}" -o/dev/null localhost:3000/your-controller/path?format=json
=> 200 # expected, params[:format] == :json

curl -sSL -w "%{http_code}" -o/dev/null -H 'Content-Type: application/json' localhost:3000/your-controller/path
=> 422 # WTF?

What the actual? Isn’t that a JSON??? Well…

It all boils down to that tricky request.format bit.

# actionpack/lib/action_dispatch/http/mime_negotiation.rb
# [...]
def format(_view_path = nil)
  formats.first || Mime::NullType.instance
end

def formats
  fetch_header("action_dispatch.request.formats") do |k|
    v = if params_readable?
      Array(Mime[parameters[:format]]) # Writer's Note: this means query-string or request body has: format=json
    elsif use_accept_header && valid_accept_header # Writer's Note: this means: Has "Accept: application/json" ?
      accepts.dup
    elsif extension_format = format_from_path_extension
      [extension_format] # Writer's Note: this means "request uri ends_with '.json'?"
    elsif xhr?
      [Mime[:js]]
    else
      [Mime[:html]]
    end

    v.select! do |format|
      format.symbol || format.ref == "*/*"
    end

    set_header k, v
  end
end
# [...]

TL;DR: what you told rails was:

If the request does not [either of the following]

  • have format=json in either query string or parseable request body.
  • have an Accept header that is recognized as json: application/json, application/jsonrequest, application/problem+json
  • have a path ending .json extension Reject it.

And that’s not quite equivalent… You see, from MDN Docs on HTTP Accept Header:

The Accept request HTTP header indicates which content types, expressed as MIME types, the client is able to understand. […]

The Accept header is used strictly from the client perspective to say: “Please gimme a %content-type%, that’s what I can read”.

Side note: It’s fun to me too that to check the format the body _is parsed 😂._

In any case what you probably meant to write was:

head :unprocessable_entity unless request.content_mime_type.json?

Which really says: “Reject the request if the client did not provide a json”

This _“new friend” is defined a couple of lines above our mischievous pal ActionDispatch::Request#format

def content_mime_type
  fetch_header("action_dispatch.request.content_type") do |k|
    v = if get_header("CONTENT_TYPE") =~ /^([^,;]*)/
      Mime::Type.lookup($1.strip.downcase)
    else
      nil
    end
    set_header k, v
  rescue ::Mime::Type::InvalidMimeType => e
    raise InvalidType, e.message
  end
end

This one instead tries to lookup the value of the Content-Type header, which according to MDN Docs on HTTP Content-Type:

The Content-Type representation header is used to indicate the original media type of the resource (prior to any content encoding applied for sending). […] In requests, (such as POST or PUT), the client tells the server what type of data is actually sent.

Lesson learned: request.format is for Response format negotiation (What to send to the client), DO NOT ATTEMPT TO USE IT TO DETERMINE IF A CLIENT SENT YOU A JSON, IT WILL FAIL MISERABLY AND YOU’LL JUST PUT A .json AT THE END AND RUIN YOUR SHINY BEAUTIFUL SMART ENDPOINT