kemalcr / kemal

Fast, Effective, Simple Web Framework

Home Page:https://kemalcr.com

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Kemal should not generate a HEAD handler implicitly from a GET handler

hholst80 opened this issue · comments

Description

get should not implicitly define a head response.

Use case. A service that provides health-check pages. A monitor would use a HEAD to obtain the status-code (which maps to the health of the application). The current logic bypasses any call to the actual get logic and always returns a 200 independently of what the get logic. I might be wrong but I think that is even against the HTTP RFC(s). A HTTP HEAD should return the same headers as the GET call would have returned, just without the payload.

RFC violation or not, the HEAD logic is not helpful for this use-case. It actually is hurting the user. Let a user define their own head logic if they need it. The current logic is probably just broken, the get logic must be called in any instance, to get the correct headers to pass back to the client.

Steps to Reproduce

# Adds a given route to routing tree. As an exception each `GET` route additionaly defines

Expected behavior: [What you expect to happen]

Kemal should not auto-generate a bogus HEAD handler. In any instance it should return the same header values as the corresponding GET would have.

Actual behavior: [What actually happens]

Returns a Status 200 for any response matching the pattern of the corresponding get.

Versions

1.0.0 Crystal and Kemal

I ran into this also. Same use case: an external automated health check service is hitting my Kemal server with a HEAD request, and I was surprised to see that the GET handler code was never run.

I looked at Rails, Sinatra, Express.js, Django -- and all of them still evaluate the GET controller code but just drop the response body for a HEAD request.

I suspect that in Kemal::RouteHandler#add_route, the line:

      add_to_radix_tree("HEAD", path, Route.new("HEAD", path) { }) if method == "GET"

probably needs to be changed to something roughly like this:

      add_to_radix_tree("HEAD", path, Route.new("HEAD", path) { |context| handler.call(context) ; nil }) if method == "GET"

(Did not test.) @sdogruyol would the above proposed fix cover it or am I missing something? Thanks 😄

@compumike that approach sounds OK 👍 However, I'm also thinking if Kemal should totally drop auto-generated HEADs. WDYT @straight-shoota ?

I don't have a strong opinion on this. But since other webframeworks apprently implicitly generate HEAD requests, it's probably good to do the same.

I'm not sure how you'd go about it, but ideally the response would have a content-length of whatever the related GET request would have been.

I've created a PR for this here: #655

It creates a new HeadRequestHandler which includes a NullIO which captures everything written to the response in order to set a valid Content-Length header as @Blacksmoke16 mentioned, but drops the response body.

Let me know what you think! 😆

I'm wondering if there could be a convenient solution for avoiding to copy the data on a head request.

For, example when serving a file, you don't need to load the file and pipe it into the null IO to get the size when you could just pass the file size directly.
Of course you could just add a dedicated HEAD handler for this, but it's much more convenient if you have a single handler for GET and HEAD

I'm thinking about writing something like this in a handler:

if output = context.response.output.as?(HeadRequestHandler::NullIO)
  # HEAD request, no need to read data. Size is enough
  output.out_count += File.size(filename)
else
  # GET request, read the actual data
  File.open(filename) do |file|
    IO.copy(file, context.response)
  end
end

As a detail: that wouldn't work if there were a HTTP::CompressHandler in the chain. Or specifically for Kemal, there's a send_file method in src/kemal/helpers/helpers.cr which does conditional compression, used by StaticFileHandler.

(My understanding of the standards is that if the GET request with Accept-Encoding: gzip would return Content-Encoding: gzip\nContent-Length: 25\n, where the post-compression size is 25 bytes, then the HEAD request must also do so. So actually compressing the file contents and discarding the output would be necessary in this case.)

Edit: on closer reading of RFC 7231, it seems like it may be valid to omit the Content-Length header entirely in a HEAD response:

payload header fields MAY be omitted

But that does not seem to be how other implementations behave in practice. And regardless, you have to run the GET handler in order to produce any other headers that may be generated.

This is now fixed via #614 👍