Skip to content

Commit

Permalink
Add stream method to InsideRoute which sets appropriate headers for…
Browse files Browse the repository at this point in the history
… Rack::Chunked.

Also necessary was the introduction of an object (`FileResponse`) so the formatting middleware would leave files and
other enumerables alone.
  • Loading branch information
Zachary Belzer committed Jul 30, 2015
1 parent b5c83a4 commit 46bcec8
Show file tree
Hide file tree
Showing 8 changed files with 161 additions and 18 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Next Release
* [#1039](https://github.com/intridea/grape/pull/1039): Added support for custom parameter types - [@rnubel](https://github.com/rnubel).
* [#1047](https://github.com/intridea/grape/pull/1047): Adds `given` to DSL::Parameters, allowing for dependent params - [@rnubel](https://github.com/rnubel).
* [#1064](https://github.com/intridea/grape/pull/1064): Add public `Grape::Exception::ValidationErrors#full_messages` - [@romanlehnert](https://github.com/romanlehnert).
* [#](https://github.com/intridea/grape/pull/): Added `stream` method to take advantage of `Rack::Chunked` [@zbelzer](https://github.com/zbelzer).
* Your contribution here!

#### Fixes
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2105,6 +2105,8 @@ end
Use `body false` to return `204 No Content` without any data or content-type.
You can also set the response to a file-like object with `file`.
Note: Rack will read your entire Enumerable before returning a response. If
you would like to stream the response, see `stream`.
```ruby
class FileStreamer
Expand All @@ -2126,6 +2128,16 @@ class API < Grape::API
end
```
If you want a file-like object to be streamed using Rack::Chunked, use `stream`.
```ruby
class API < Grape::API
get '/' do
stream FileStreamer.new('file.bin')
end
end
```
## Authentication
### Basic and Digest Auth
Expand Down
1 change: 1 addition & 0 deletions lib/grape.rb
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ module Util
autoload :StackableValues
autoload :InheritableSetting
autoload :StrictHashConfiguration
autoload :FileResponse
end

module DSL
Expand Down
24 changes: 23 additions & 1 deletion lib/grape/dsl/inside_route.rb
Original file line number Diff line number Diff line change
Expand Up @@ -177,12 +177,34 @@ def body(value = nil)
# GET /file # => "contents of file"
def file(value = nil)
if value
@file = value
@file = Grape::Util::FileResponse.new(value)
else
@file
end
end

# Allows you to define the response as a streamable object.
#
# If Content-Length and Transfer-Encoding are blank (among other conditions),
# Rack assumes this response can be streamed in chunks.
#
# @example
# get '/stream' do
# stream FileStreamer.new(...)
# end
#
# GET /stream # => "chunked contents of file"
#
# See:
# * https://github.com/rack/rack/blob/99293fa13d86cd48021630fcc4bd5acc9de5bdc3/lib/rack/chunked.rb
# * https://github.com/rack/rack/blob/99293fa13d86cd48021630fcc4bd5acc9de5bdc3/lib/rack/etag.rb
def stream(value = nil)
header "Content-Length", nil
header "Transfer-Encoding", nil
header "Cache-Control", "no-cache" # Skips ETag generation (reading the response up front)
file(value)
end

# Allows you to make use of Grape Entities by setting
# the response body to the serializable hash of the
# entity provided in the `:with` option. This has the
Expand Down
50 changes: 35 additions & 15 deletions lib/grape/middleware/formatter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,46 @@ def before

def after
status, headers, bodies = *@app_response
# allow content-type to be explicitly overwritten
api_format = mime_types[headers[Grape::Http::Headers::CONTENT_TYPE]] || env['api.format']
formatter = Grape::Formatter::Base.formatter_for api_format, options
begin
bodymap = if bodies.respond_to?(:collect)
bodies.collect do |body|
formatter.call body, env
end
else
bodies
end
rescue Grape::Exceptions::InvalidFormatter => e
throw :error, status: 500, message: e.message

if bodies.is_a?(Grape::Util::FileResponse)
set_content_type!(headers)

response =
Rack::Response.new([], status, headers) do |resp|
resp.body = bodies.file
end
else
# Allow content-type to be explicitly overwritten
api_format = mime_types[headers[Grape::Http::Headers::CONTENT_TYPE]] || env['api.format']
formatter = Grape::Formatter::Base.formatter_for(api_format, options)

begin
bodymap = bodies.collect do |body|
formatter.call(body, env)
end

set_content_type!(headers)

response = Rack::Response.new(bodymap, status, headers)
rescue Grape::Exceptions::InvalidFormatter => e
throw :error, status: 500, message: e.message
end
end
headers[Grape::Http::Headers::CONTENT_TYPE] = content_type_for(env['api.format']) unless headers[Grape::Http::Headers::CONTENT_TYPE]
Rack::Response.new(bodymap, status, headers)

response
end

private

# Set the content type header for the API format if it is not already present.
#
# @param headers [Hash]
def set_content_type!(headers)
unless headers[Grape::Http::Headers::CONTENT_TYPE]
headers[Grape::Http::Headers::CONTENT_TYPE] = content_type_for(env['api.format'])
end
end

def request
@request ||= Rack::Request.new(env)
end
Expand Down
21 changes: 21 additions & 0 deletions lib/grape/util/file_response.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
module Grape
module Util
# A simple class used to identify responses which represent files and do not
# need to be formatted or pre-read by Rack::Response
class FileResponse
attr_reader :file

# @param file [Object]
def initialize(file)
@file = file
end

# Equality provided mostly for tests.
#
# @return [Boolean]
def ==(other)
self.file == other.file
end
end
end
end
31 changes: 31 additions & 0 deletions spec/grape/api_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,37 @@ def subject.enable_root_route!
expect(last_response.body).to eq(file)
end

it 'returns the content of the file with file' do
file_content = "This is some file content"
test_file = Tempfile.new('test')
test_file.write file_content
test_file.rewind

subject.get('/file') { file test_file }
get '/file'
expect(last_response.headers['Content-Length']).to eq("25")
expect(last_response.headers['Content-Type']).to eq("text/plain")
expect(last_response.body).to eq(file_content)
end

it 'streams the content of the file with stream' do
test_stream = Enumerator.new do |blk|
blk.yield "This is some"
blk.yield " file content"
end

subject.use Rack::Chunked
subject.get('/stream') { stream test_stream }
get '/stream', {}, 'HTTP_VERSION' => 'HTTP/1.1'

expect(last_response.headers['Content-Type']).to eq("text/plain")
expect(last_response.headers['Content-Length']).to eq(nil)
expect(last_response.headers['Cache-Control']).to eq("no-cache")
expect(last_response.headers['Transfer-Encoding']).to eq("chunked")

expect(last_response.body).to eq("c\r\nThis is some\r\nd\r\n file content\r\n0\r\n\r\n")
end

it 'sets content type for error' do
subject.get('/error') { error!('error in plain text', 500) }
get '/error'
Expand Down
39 changes: 37 additions & 2 deletions spec/grape/dsl/inside_route_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,43 @@ def initialize
subject.file 'file'
end

it 'returns value' do
expect(subject.file).to eq 'file'
it 'returns value wrapped in FileResponse' do
expect(subject.file).to eq Grape::Util::FileResponse.new('file')
end
end

it 'returns default' do
expect(subject.file).to be nil
end
end

describe '#stream' do
describe 'set' do
before do
subject.header 'Cache-Control', "cache"
subject.header 'Content-Length', 123
subject.header 'Transfer-Encoding', "base64"
subject.stream 'file'
end

it 'returns value wrapped in FileResponse' do
expect(subject.stream).to eq Grape::Util::FileResponse.new('file')
end

it 'also sets result of file to value wrapped in FileResponse' do
expect(subject.file).to eq Grape::Util::FileResponse.new('file')
end

it 'sets Cache-Control header to no-cache' do
expect(subject.header['Cache-Control']).to eq "no-cache"
end

it 'sets Content-Length header to nil' do
expect(subject.header['Content-Length']).to eq nil
end

it 'sets Transfer-Encoding header to nil' do
expect(subject.header['Transfer-Encoding']).to eq nil
end
end

Expand Down

0 comments on commit 46bcec8

Please sign in to comment.