Skip to content

Commit df3d7df

Browse files
committed
Extract send_file plugin from sinatra_helpers plugin (Closes #412)
A couple differences: * send_file is a scope method instead of a request method. By default, sinatra_helpers adds a scope method for it that calls the request method, and typical usage probably calls the scope method. * To avoid bringing in the content_type/mime_type methods, some of the logic is now inlined into send_file.
1 parent 5466b69 commit df3d7df

6 files changed

Lines changed: 238 additions & 67 deletions

File tree

CHANGELOG

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
=== master
22

3+
* Extract send_file plugin from sinatra_helpers plugin (jeremyevans) (#412)
4+
35
* Extract response_attachment plugin from sinatra_helpers plugin (jeremyevans)
46

57
=== 3.101.0 (2026-02-13)

lib/roda/plugins/send_file.rb

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# frozen-string-literal: true
2+
3+
begin
4+
require 'rack/files'
5+
rescue LoadError
6+
require 'rack/file'
7+
end
8+
9+
#
10+
class Roda
11+
module RodaPlugins
12+
# The send_file plugin adds a send_file method, used for
13+
# returning the contents of a file as the body of a request.
14+
# It also loads the response_attachment plugin to set the
15+
# Content-Disposition and Content-Type based on the file's
16+
# extension.
17+
#
18+
# senf_file will serve the file with the given path from the file system:
19+
#
20+
# send_file 'path/to/file.txt'
21+
#
22+
# Options:
23+
#
24+
# :disposition :: Set the Content-Disposition to the given disposition.
25+
# :filename :: Set the Content-Disposition to attachment (unless :disposition is set),
26+
# and set the filename parameter to the value.
27+
# :last_modified :: Explicitly set the Last-Modified header to the given value, and
28+
# return a not modified response if there has not been modified since
29+
# the previous request. This option requires the caching plugin.
30+
# :status :: Override the status for the response.
31+
# :type :: Set the Content-Type to use for this response.
32+
#
33+
# == License
34+
#
35+
# The implementation was originally taken from Sinatra,
36+
# which is also released under the MIT License:
37+
#
38+
# Copyright (c) 2007, 2008, 2009 Blake Mizerany
39+
# Copyright (c) 2010, 2011, 2012, 2013, 2014 Konstantin Haase
40+
#
41+
# Permission is hereby granted, free of charge, to any person
42+
# obtaining a copy of this software and associated documentation
43+
# files (the "Software"), to deal in the Software without
44+
# restriction, including without limitation the rights to use,
45+
# copy, modify, merge, publish, distribute, sublicense, and/or sell
46+
# copies of the Software, and to permit persons to whom the
47+
# Software is furnished to do so, subject to the following
48+
# conditions:
49+
#
50+
# The above copyright notice and this permission notice shall be
51+
# included in all copies or substantial portions of the Software.
52+
#
53+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
54+
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
55+
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
56+
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
57+
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
58+
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
59+
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
60+
# OTHER DEALINGS IN THE SOFTWARE.
61+
module SendFile
62+
RACK_FILES = defined?(Rack::Files) ? Rack::Files : Rack::File
63+
64+
# Depend on the status_303 plugin.
65+
def self.load_dependencies(app)
66+
app.plugin :response_attachment
67+
end
68+
69+
module InstanceMethods
70+
# Use the contents of the file at +path+ as the response body. See plugin documentation for options.
71+
def send_file(path, opts = OPTS)
72+
r = @_request
73+
res = @_response
74+
headers = res.headers
75+
if (type = opts[:type]) || !headers[RodaResponseHeaders::CONTENT_TYPE]
76+
type_str = type.to_s
77+
78+
if type_str.include?('/')
79+
type = type_str
80+
else
81+
if type
82+
type = ".#{type}" unless type_str.start_with?(".")
83+
else
84+
type = ::File.extname(path)
85+
end
86+
87+
type &&= Rack::Mime.mime_type(type, nil)
88+
type ||= 'application/octet-stream'
89+
end
90+
91+
headers[RodaResponseHeaders::CONTENT_TYPE] = type
92+
end
93+
94+
disposition = opts[:disposition]
95+
filename = opts[:filename]
96+
if disposition || filename
97+
disposition ||= 'attachment'
98+
filename = path if filename.nil?
99+
res.attachment(filename, disposition)
100+
end
101+
102+
if lm = opts[:last_modified]
103+
r.last_modified(lm)
104+
end
105+
106+
file = RACK_FILES.new nil
107+
s, h, b = if Rack.release > '2'
108+
file.serving(r, path)
109+
else
110+
file.path = path
111+
file.serving(env)
112+
end
113+
114+
res.status = opts[:status] || s
115+
headers.delete(RodaResponseHeaders::CONTENT_LENGTH)
116+
headers.replace(h.merge!(headers))
117+
r.halt res.finish_with_body(b)
118+
rescue Errno::ENOENT
119+
response.status = 404
120+
r.halt
121+
end
122+
end
123+
end
124+
125+
register_plugin(:send_file, SendFile)
126+
end
127+
end

lib/roda/plugins/sinatra_helpers.rb

Lines changed: 6 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
11
# frozen-string-literal: true
22

33
require 'rack/mime'
4-
begin
5-
require 'rack/files'
6-
rescue LoadError
7-
require 'rack/file'
8-
end
9-
104

115
#
126
class Roda
@@ -91,20 +85,7 @@ module RodaPlugins
9185
#
9286
# === send_file
9387
#
94-
# This will serve the file with the given path from the file system:
95-
#
96-
# send_file 'path/to/file.txt'
97-
#
98-
# Options:
99-
#
100-
# :disposition :: Set the Content-Disposition to the given disposition.
101-
# :filename :: Set the Content-Disposition to attachment (unless :disposition is set),
102-
# and set the filename parameter to the value.
103-
# :last_modified :: Explicitly set the Last-Modified header to the given value, and
104-
# return a not modified response if there has not been modified since
105-
# the previous request. This option requires the caching plugin.
106-
# :status :: Override the status for the response.
107-
# :type :: Set the Content-Type to use for this response.
88+
# See send_file plugin documentation for details.
10889
#
10990
# == Response Methods Added
11091
#
@@ -166,15 +147,7 @@ module RodaPlugins
166147
#
167148
# === attachment
168149
#
169-
# When called with no filename, +attachment+ just sets the Content-Disposition
170-
# to attachment. When called with a filename, this sets the Content-Disposition
171-
# to attachment with the appropriate filename parameter, and if the filename
172-
# extension is recognized, this also sets the Content-Type to the appropriate
173-
# MIME type if not already set.
174-
#
175-
# attachment # set Content-Disposition to 'attachment'
176-
# attachment 'a.csv' # set Content-Disposition to 'attachment;filename="a.csv"',
177-
# # also set Content-Type to 'text/csv'
150+
# See response_attachment plugin for details.
178151
#
179152
# === status predicates
180153
#
@@ -219,12 +192,10 @@ module RodaPlugins
219192
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
220193
# OTHER DEALINGS IN THE SOFTWARE.
221194
module SinatraHelpers
222-
RACK_FILES = defined?(Rack::Files) ? Rack::Files : Rack::File
223-
224195
# Depend on the status_303 plugin.
225196
def self.load_dependencies(app, _opts = nil)
226197
app.plugin :status_303
227-
app.plugin :response_attachment
198+
app.plugin :send_file
228199
end
229200

230201
# Add delegate methods to the route block scope
@@ -320,40 +291,9 @@ def redirect(path=(no_add_script_name = true; default_redirect_path), status=def
320291
super(path, status)
321292
end
322293

323-
# Use the contents of the file at +path+ as the response body. See plugin documentation for options.
294+
# Backwards compatibility for callers of r.send_file.
324295
def send_file(path, opts = OPTS)
325-
res = response
326-
headers = res.headers
327-
if opts[:type] || !headers[RodaResponseHeaders::CONTENT_TYPE]
328-
res.content_type(opts[:type] || ::File.extname(path), :default => 'application/octet-stream')
329-
end
330-
331-
disposition = opts[:disposition]
332-
filename = opts[:filename]
333-
if disposition || filename
334-
disposition ||= 'attachment'
335-
filename = path if filename.nil?
336-
res.attachment(filename, disposition)
337-
end
338-
339-
if lm = opts[:last_modified]
340-
last_modified(lm)
341-
end
342-
343-
file = RACK_FILES.new nil
344-
s, h, b = if Rack.release > '2'
345-
file.serving(self, path)
346-
else
347-
file.path = path
348-
file.serving(@env)
349-
end
350-
351-
res.status = opts[:status] || s
352-
headers.delete(RodaResponseHeaders::CONTENT_LENGTH)
353-
headers.replace(h.merge!(headers))
354-
halt res.finish_with_body(b)
355-
rescue Errno::ENOENT
356-
not_found
296+
scope.send_file(path, opts)
357297
end
358298

359299
# Generates the absolute URI for a given path in the app.
@@ -485,7 +425,7 @@ module DelegateMethods
485425
[:logger, :back].each do |meth|
486426
define_method(meth){@_request.public_send(meth)}
487427
end
488-
[:redirect, :uri, :url, :to, :send_file, :error, :not_found].each do |meth|
428+
[:redirect, :uri, :url, :to, :error, :not_found].each do |meth|
489429
define_method(meth){|*v, &block| @_request.public_send(meth, *v, &block)}
490430
end
491431

spec/plugin/send_file_spec.rb

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
require_relative "../spec_helper"
2+
3+
require 'uri'
4+
5+
describe 'send_file plugin' do
6+
before do
7+
file = @file = 'spec/assets/css/raw.css'
8+
@content = File.read(@file)
9+
app(:send_file) do |r|
10+
send_file file, env['rack.OPTS'] || {}
11+
end
12+
end
13+
14+
it "sends the contents of the file" do
15+
status.must_equal 200
16+
body.must_equal @content
17+
end
18+
19+
it "returns response body implementing to_path" do
20+
req[2].to_path.must_equal @file
21+
end if !ENV['LINT'] || Rack.release >= '3'
22+
23+
it 'sets the Content-Type response header if a mime-type can be located' do
24+
header(RodaResponseHeaders::CONTENT_TYPE).must_equal 'text/css'
25+
end
26+
27+
it 'sets the Content-Type response header if type option is set to a file extension' do
28+
header(RodaResponseHeaders::CONTENT_TYPE, 'rack.OPTS'=>{:type => 'html'}).must_equal 'text/html'
29+
end
30+
31+
it 'sets the Content-Type response header if type option is set to a mime type' do
32+
header(RodaResponseHeaders::CONTENT_TYPE, 'rack.OPTS'=>{:type => 'application/octet-stream'}).must_equal 'application/octet-stream'
33+
end
34+
35+
it 'sets the Content-Length response header' do
36+
header(RodaResponseHeaders::CONTENT_LENGTH).must_equal @content.length.to_s
37+
end
38+
39+
it 'sets the Last-Modified response header' do
40+
header(RodaResponseHeaders::LAST_MODIFIED).must_equal File.mtime(@file).httpdate
41+
end
42+
43+
it 'allows passing in a different Last-Modified response header with :last_modified' do
44+
time = Time.now
45+
@app.plugin :caching
46+
header(RodaResponseHeaders::LAST_MODIFIED, 'rack.OPTS'=>{:last_modified => time}).must_equal time.httpdate
47+
end
48+
49+
it "returns a 404 when not found" do
50+
app(:send_file) do |r|
51+
send_file 'this-file-does-not-exist.txt'
52+
end
53+
status.must_equal 404
54+
end
55+
56+
it "does not set the Content-Disposition header by default" do
57+
header(RodaResponseHeaders::CONTENT_DISPOSITION).must_be_nil
58+
end
59+
60+
it "sets the Content-Disposition header when :disposition set to 'attachment'" do
61+
header(RodaResponseHeaders::CONTENT_DISPOSITION, 'rack.OPTS'=>{:disposition => 'attachment'}).must_equal 'attachment; filename="raw.css"'
62+
end
63+
64+
it "does not set add a file name if filename is false" do
65+
header(RodaResponseHeaders::CONTENT_DISPOSITION, 'rack.OPTS'=>{:disposition => 'inline', :filename=>false}).must_equal 'inline'
66+
end
67+
68+
it "sets the Content-Disposition header when :disposition set to 'inline'" do
69+
header(RodaResponseHeaders::CONTENT_DISPOSITION, 'rack.OPTS'=>{:disposition => 'inline'}).must_equal 'inline; filename="raw.css"'
70+
end
71+
72+
it "sets the Content-Disposition header when :filename provided" do
73+
header(RodaResponseHeaders::CONTENT_DISPOSITION, 'rack.OPTS'=>{:filename => 'foo.txt'}).must_equal 'attachment; filename="foo.txt"'
74+
end
75+
76+
it 'allows setting a custom status code' do
77+
status('rack.OPTS'=>{:status=>201}).must_equal 201
78+
end
79+
80+
it "is able to send files with unknown mime type" do
81+
header(RodaResponseHeaders::CONTENT_TYPE, 'rack.OPTS'=>{:type => '.foobar'}).must_equal 'application/octet-stream'
82+
end
83+
84+
it "does not override Content-Type if already set and no explicit type is given" do
85+
file = @file
86+
app(:send_file) do |r|
87+
response[RodaResponseHeaders::CONTENT_TYPE] = "image/png"
88+
send_file file
89+
end
90+
header(RodaResponseHeaders::CONTENT_TYPE).must_equal 'image/png'
91+
end
92+
93+
it "does override Content-Type even if already set, if explicit type is given" do
94+
file = @file
95+
app(:send_file) do |r|
96+
response[RodaResponseHeaders::CONTENT_TYPE] = "image/png"
97+
send_file file, :type => :gif
98+
end
99+
header(RodaResponseHeaders::CONTENT_TYPE).must_equal 'image/gif'
100+
end
101+
end

spec/plugin/sinatra_helpers_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ def o.each; yield 'Hello World' end
382382
before(:all) do
383383
file = @file = 'spec/assets/css/raw.css'
384384
@content = File.read(@file)
385-
sin_app{send_file file, env['rack.OPTS'] || {}}
385+
sin_app{request.send_file file, env['rack.OPTS'] || {}}
386386
end
387387

388388
it "sends the contents of the file" do

www/pages/documentation.erb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@
126126
<li><a href="rdoc/classes/Roda/RodaPlugins/ResponseAttachment.html">response_attachment</a>: More easily set content-disposition and content-type headers for attachments.</li>
127127
<li><a href="rdoc/classes/Roda/RodaPlugins/ResponseContentType.html">response_content_type</a>: More easily set content-type header for responses.</li>
128128
<li><a href="rdoc/classes/Roda/RodaPlugins/ResponseRequest.html">response_request</a>: Gives response object access to request object.</li>
129+
<li><a href="rdoc/classes/Roda/RodaPlugins/SendFile.html">send_file</a>: Adds send_file method for returning file content as a response.</li>
129130
<li><a href="rdoc/classes/Roda/RodaPlugins/SinatraHelpers.html">sinatra_helpers</a>: Port of Sinatra::Helpers methods not covered by other plugins.</li>
130131
<li><a href="rdoc/classes/Roda/RodaPlugins/Status303.html">status_303</a>: Uses 303 as the default redirect status for non-GET requests by HTTP 1.1 clients.</li>
131132
<li><a href="rdoc/classes/Roda/RodaPlugins/SymbolStatus.html">symbol_status</a>: Allows the use of symbols as status codes, converting them to the appropriate integer.</li>

0 commit comments

Comments
 (0)