Skip to content

Commit 0dfc1cc

Browse files
committed
Merge pull request #1079 from zbelzer/support_streaming
Add `stream` method to InsideRoute to leverage Rack::Chunked
2 parents b5c83a4 + 8e2d247 commit 0dfc1cc

File tree

9 files changed

+165
-19
lines changed

9 files changed

+165
-19
lines changed

.rubocop_todo.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Metrics/MethodLength:
3535
# Offense count: 8
3636
# Configuration parameters: CountComments.
3737
Metrics/ModuleLength:
38-
Max: 243
38+
Max: 271
3939

4040
# Offense count: 17
4141
Metrics/PerceivedComplexity:

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Next Release
77
* [#1039](https://github.com/intridea/grape/pull/1039): Added support for custom parameter types - [@rnubel](https://github.com/rnubel).
88
* [#1047](https://github.com/intridea/grape/pull/1047): Adds `given` to DSL::Parameters, allowing for dependent params - [@rnubel](https://github.com/rnubel).
99
* [#1064](https://github.com/intridea/grape/pull/1064): Add public `Grape::Exception::ValidationErrors#full_messages` - [@romanlehnert](https://github.com/romanlehnert).
10+
* [#1079](https://github.com/intridea/grape/pull/1079): Added `stream` method to take advantage of `Rack::Chunked` [@zbelzer](https://github.com/zbelzer).
1011
* Your contribution here!
1112

1213
#### Fixes

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2105,6 +2105,8 @@ end
21052105
Use `body false` to return `204 No Content` without any data or content-type.
21062106
21072107
You can also set the response to a file-like object with `file`.
2108+
Note: Rack will read your entire Enumerable before returning a response. If
2109+
you would like to stream the response, see `stream`.
21082110
21092111
```ruby
21102112
class FileStreamer
@@ -2126,6 +2128,16 @@ class API < Grape::API
21262128
end
21272129
```
21282130
2131+
If you want a file-like object to be streamed using Rack::Chunked, use `stream`.
2132+
2133+
```ruby
2134+
class API < Grape::API
2135+
get '/' do
2136+
stream FileStreamer.new('file.bin')
2137+
end
2138+
end
2139+
```
2140+
21292141
## Authentication
21302142
21312143
### Basic and Digest Auth

lib/grape.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ module Util
122122
autoload :StackableValues
123123
autoload :InheritableSetting
124124
autoload :StrictHashConfiguration
125+
autoload :FileResponse
125126
end
126127

127128
module DSL

lib/grape/dsl/inside_route.rb

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,12 +177,34 @@ def body(value = nil)
177177
# GET /file # => "contents of file"
178178
def file(value = nil)
179179
if value
180-
@file = value
180+
@file = Grape::Util::FileResponse.new(value)
181181
else
182182
@file
183183
end
184184
end
185185

186+
# Allows you to define the response as a streamable object.
187+
#
188+
# If Content-Length and Transfer-Encoding are blank (among other conditions),
189+
# Rack assumes this response can be streamed in chunks.
190+
#
191+
# @example
192+
# get '/stream' do
193+
# stream FileStreamer.new(...)
194+
# end
195+
#
196+
# GET /stream # => "chunked contents of file"
197+
#
198+
# See:
199+
# * https://github.com/rack/rack/blob/99293fa13d86cd48021630fcc4bd5acc9de5bdc3/lib/rack/chunked.rb
200+
# * https://github.com/rack/rack/blob/99293fa13d86cd48021630fcc4bd5acc9de5bdc3/lib/rack/etag.rb
201+
def stream(value = nil)
202+
header 'Content-Length', nil
203+
header 'Transfer-Encoding', nil
204+
header 'Cache-Control', 'no-cache' # Skips ETag generation (reading the response up front)
205+
file(value)
206+
end
207+
186208
# Allows you to make use of Grape Entities by setting
187209
# the response body to the serializable hash of the
188210
# entity provided in the `:with` option. This has the

lib/grape/middleware/formatter.rb

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,26 +18,49 @@ def before
1818

1919
def after
2020
status, headers, bodies = *@app_response
21-
# allow content-type to be explicitly overwritten
22-
api_format = mime_types[headers[Grape::Http::Headers::CONTENT_TYPE]] || env['api.format']
23-
formatter = Grape::Formatter::Base.formatter_for api_format, options
24-
begin
25-
bodymap = if bodies.respond_to?(:collect)
26-
bodies.collect do |body|
27-
formatter.call body, env
28-
end
29-
else
30-
bodies
31-
end
32-
rescue Grape::Exceptions::InvalidFormatter => e
33-
throw :error, status: 500, message: e.message
21+
22+
if bodies.is_a?(Grape::Util::FileResponse)
23+
headers = ensure_content_type(headers)
24+
25+
response =
26+
Rack::Response.new([], status, headers) do |resp|
27+
resp.body = bodies.file
28+
end
29+
else
30+
# Allow content-type to be explicitly overwritten
31+
api_format = mime_types[headers[Grape::Http::Headers::CONTENT_TYPE]] || env['api.format']
32+
formatter = Grape::Formatter::Base.formatter_for(api_format, options)
33+
34+
begin
35+
bodymap = bodies.collect do |body|
36+
formatter.call(body, env)
37+
end
38+
39+
headers = ensure_content_type(headers)
40+
41+
response = Rack::Response.new(bodymap, status, headers)
42+
rescue Grape::Exceptions::InvalidFormatter => e
43+
throw :error, status: 500, message: e.message
44+
end
3445
end
35-
headers[Grape::Http::Headers::CONTENT_TYPE] = content_type_for(env['api.format']) unless headers[Grape::Http::Headers::CONTENT_TYPE]
36-
Rack::Response.new(bodymap, status, headers)
46+
47+
response
3748
end
3849

3950
private
4051

52+
# Set the content type header for the API format if it is not already present.
53+
#
54+
# @param headers [Hash]
55+
# @return [Hash]
56+
def ensure_content_type(headers)
57+
if headers[Grape::Http::Headers::CONTENT_TYPE]
58+
headers
59+
else
60+
headers.merge(Grape::Http::Headers::CONTENT_TYPE => content_type_for(env['api.format']))
61+
end
62+
end
63+
4164
def request
4265
@request ||= Rack::Request.new(env)
4366
end

lib/grape/util/file_response.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
module Grape
2+
module Util
3+
# A simple class used to identify responses which represent files and do not
4+
# need to be formatted or pre-read by Rack::Response
5+
class FileResponse
6+
attr_reader :file
7+
8+
# @param file [Object]
9+
def initialize(file)
10+
@file = file
11+
end
12+
13+
# Equality provided mostly for tests.
14+
#
15+
# @return [Boolean]
16+
def ==(other)
17+
file == other.file
18+
end
19+
end
20+
end
21+
end

spec/grape/api_spec.rb

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,37 @@ def subject.enable_root_route!
795795
expect(last_response.body).to eq(file)
796796
end
797797

798+
it 'returns the content of the file with file' do
799+
file_content = 'This is some file content'
800+
test_file = Tempfile.new('test')
801+
test_file.write file_content
802+
test_file.rewind
803+
804+
subject.get('/file') { file test_file }
805+
get '/file'
806+
expect(last_response.headers['Content-Length']).to eq('25')
807+
expect(last_response.headers['Content-Type']).to eq('text/plain')
808+
expect(last_response.body).to eq(file_content)
809+
end
810+
811+
it 'streams the content of the file with stream' do
812+
test_stream = Enumerator.new do |blk|
813+
blk.yield 'This is some'
814+
blk.yield ' file content'
815+
end
816+
817+
subject.use Rack::Chunked
818+
subject.get('/stream') { stream test_stream }
819+
get '/stream', {}, 'HTTP_VERSION' => 'HTTP/1.1'
820+
821+
expect(last_response.headers['Content-Type']).to eq('text/plain')
822+
expect(last_response.headers['Content-Length']).to eq(nil)
823+
expect(last_response.headers['Cache-Control']).to eq('no-cache')
824+
expect(last_response.headers['Transfer-Encoding']).to eq('chunked')
825+
826+
expect(last_response.body).to eq("c\r\nThis is some\r\nd\r\n file content\r\n0\r\n\r\n")
827+
end
828+
798829
it 'sets content type for error' do
799830
subject.get('/error') { error!('error in plain text', 500) }
800831
get '/error'

spec/grape/dsl/inside_route_spec.rb

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,43 @@ def initialize
201201
subject.file 'file'
202202
end
203203

204-
it 'returns value' do
205-
expect(subject.file).to eq 'file'
204+
it 'returns value wrapped in FileResponse' do
205+
expect(subject.file).to eq Grape::Util::FileResponse.new('file')
206+
end
207+
end
208+
209+
it 'returns default' do
210+
expect(subject.file).to be nil
211+
end
212+
end
213+
214+
describe '#stream' do
215+
describe 'set' do
216+
before do
217+
subject.header 'Cache-Control', 'cache'
218+
subject.header 'Content-Length', 123
219+
subject.header 'Transfer-Encoding', 'base64'
220+
subject.stream 'file'
221+
end
222+
223+
it 'returns value wrapped in FileResponse' do
224+
expect(subject.stream).to eq Grape::Util::FileResponse.new('file')
225+
end
226+
227+
it 'also sets result of file to value wrapped in FileResponse' do
228+
expect(subject.file).to eq Grape::Util::FileResponse.new('file')
229+
end
230+
231+
it 'sets Cache-Control header to no-cache' do
232+
expect(subject.header['Cache-Control']).to eq 'no-cache'
233+
end
234+
235+
it 'sets Content-Length header to nil' do
236+
expect(subject.header['Content-Length']).to eq nil
237+
end
238+
239+
it 'sets Transfer-Encoding header to nil' do
240+
expect(subject.header['Transfer-Encoding']).to eq nil
206241
end
207242
end
208243

0 commit comments

Comments
 (0)