Skip to content

Commit 90049a4

Browse files
author
David Heinemeier Hansson
authored
Add send_stream to do for dynamic streams what send_data does for static files (rails#41488)
1 parent b8d5279 commit 90049a4

File tree

3 files changed

+77
-0
lines changed

3 files changed

+77
-0
lines changed

actionpack/CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
* Add `ActionController::Live#send_stream` that makes it more convenient to send generated streams:
2+
3+
```ruby
4+
send_stream(filename: "subscribers.csv") do |stream|
5+
stream.write "email_address,updated_at\n"
6+
7+
@subscribers.find_each do |subscriber|
8+
stream.write "#{subscriber.email_address},#{subscriber.updated_at}\n"
9+
end
10+
end
11+
```
12+
13+
*DHH*
14+
115
* `ActionDispatch::Request#content_type` now returned Content-Type header as it is.
216

317
Previously, `ActionDispatch::Request#content_type` returned value does NOT contain charset part.

actionpack/lib/action_controller/metal/live.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,41 @@ def response_body=(body)
282282
response.close if response
283283
end
284284

285+
# Sends a stream to the browser, which is helpful when you're generating exports or other running data where you
286+
# don't want the entire file buffered in memory first. Similar to send_data, but where the data is generated live.
287+
#
288+
# Options:
289+
# * <tt>:filename</tt> - suggests a filename for the browser to use.
290+
# * <tt>:type</tt> - specifies an HTTP content type.
291+
# You can specify either a string or a symbol for a registered type with <tt>Mime::Type.register</tt>, for example :json.
292+
# If omitted, type will be inferred from the file extension specified in <tt>:filename</tt>.
293+
# If no content type is registered for the extension, the default type 'application/octet-stream' will be used.
294+
# * <tt>:disposition</tt> - specifies whether the file will be shown inline or downloaded.
295+
# Valid values are 'inline' and 'attachment' (default).
296+
#
297+
# Example of generating a csv export:
298+
#
299+
# send_stream(filename: "subscribers.csv") do |stream|
300+
# stream.write "email_address,updated_at\n"
301+
#
302+
# @subscribers.find_each do |subscriber|
303+
# stream.write "#{subscriber.email_address},#{subscriber.updated_at}\n"
304+
# end
305+
# end
306+
def send_stream(filename:, disposition: "attachment", type: nil)
307+
response.headers["Content-Type"] =
308+
(type.is_a?(Symbol) ? Mime[type].to_s : type) ||
309+
Mime::Type.lookup_by_extension(File.extname(filename).downcase.delete(".")) ||
310+
"application/octet-stream"
311+
312+
response.headers["Content-Disposition"] =
313+
ActionDispatch::Http::ContentDisposition.format(disposition: disposition, filename: filename)
314+
315+
yield response.stream
316+
ensure
317+
response.stream.close
318+
end
319+
285320
private
286321
# Spawn a new thread to serve up the controller in. This is to get
287322
# around the fact that Rack isn't based around IOs and we need to use

actionpack/test/controller/live_stream_test.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,18 @@ def basic_stream
144144
response.stream.close
145145
end
146146

147+
def basic_send_stream
148+
send_stream(filename: "my.csv") do |stream|
149+
stream.write "name,age\ndavid,41"
150+
end
151+
end
152+
153+
def send_stream_with_options
154+
send_stream(filename: "export", disposition: "inline", type: :json) do |stream|
155+
stream.write %[{ name: "David", age: 41 }]
156+
end
157+
end
158+
147159
def blocking_stream
148160
response.headers["Content-Type"] = "text/event-stream"
149161
%w{ hello world }.each do |word|
@@ -300,6 +312,22 @@ def test_write_to_stream
300312
assert_equal "text/event-stream", @response.headers["Content-Type"]
301313
end
302314

315+
def test_send_stream
316+
get :basic_send_stream
317+
assert_equal "name,age\ndavid,41", @response.body
318+
assert_equal "text/csv", @response.headers["Content-Type"]
319+
assert_match "attachment", @response.headers["Content-Disposition"]
320+
assert_match "my.csv", @response.headers["Content-Disposition"]
321+
end
322+
323+
def test_send_stream_with_optons
324+
get :send_stream_with_options
325+
assert_equal %[{ name: "David", age: 41 }], @response.body
326+
assert_equal "application/json", @response.headers["Content-Type"]
327+
assert_match "inline", @response.headers["Content-Disposition"]
328+
assert_match "export", @response.headers["Content-Disposition"]
329+
end
330+
303331
def test_delayed_autoload_after_write_within_interlock_hook
304332
# Simulate InterlockHook
305333
ActiveSupport::Dependencies.interlock.start_running

0 commit comments

Comments
 (0)