roda 1.0.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG +34 -0
  3. data/README.rdoc +18 -13
  4. data/Rakefile +8 -0
  5. data/doc/conventions.rdoc +163 -0
  6. data/doc/release_notes/1.1.0.txt +226 -0
  7. data/lib/roda.rb +51 -22
  8. data/lib/roda/plugins/assets.rb +613 -0
  9. data/lib/roda/plugins/caching.rb +215 -0
  10. data/lib/roda/plugins/chunked.rb +278 -0
  11. data/lib/roda/plugins/error_email.rb +112 -0
  12. data/lib/roda/plugins/flash.rb +3 -3
  13. data/lib/roda/plugins/hooks.rb +1 -1
  14. data/lib/roda/plugins/indifferent_params.rb +3 -3
  15. data/lib/roda/plugins/middleware.rb +3 -8
  16. data/lib/roda/plugins/multi_route.rb +110 -18
  17. data/lib/roda/plugins/not_allowed.rb +3 -3
  18. data/lib/roda/plugins/path.rb +38 -0
  19. data/lib/roda/plugins/render.rb +18 -16
  20. data/lib/roda/plugins/render_each.rb +0 -2
  21. data/lib/roda/plugins/streaming.rb +1 -2
  22. data/lib/roda/plugins/view_subdirs.rb +7 -1
  23. data/lib/roda/version.rb +1 -1
  24. data/spec/assets/css/app.scss +1 -0
  25. data/spec/assets/css/no_access.css +1 -0
  26. data/spec/assets/css/raw.css +1 -0
  27. data/spec/assets/js/head/app.js +1 -0
  28. data/spec/integration_spec.rb +95 -3
  29. data/spec/matchers_spec.rb +2 -2
  30. data/spec/plugin/assets_spec.rb +413 -0
  31. data/spec/plugin/caching_spec.rb +335 -0
  32. data/spec/plugin/chunked_spec.rb +182 -0
  33. data/spec/plugin/default_headers_spec.rb +6 -5
  34. data/spec/plugin/error_email_spec.rb +76 -0
  35. data/spec/plugin/multi_route_spec.rb +120 -0
  36. data/spec/plugin/not_allowed_spec.rb +14 -3
  37. data/spec/plugin/path_spec.rb +29 -0
  38. data/spec/plugin/render_each_spec.rb +6 -1
  39. data/spec/plugin/symbol_matchers_spec.rb +7 -2
  40. data/spec/request_spec.rb +10 -0
  41. data/spec/response_spec.rb +47 -0
  42. data/spec/views/about.erb +1 -0
  43. data/spec/views/about.str +1 -0
  44. data/spec/views/content-yield.erb +1 -0
  45. data/spec/views/home.erb +2 -0
  46. data/spec/views/home.str +2 -0
  47. data/spec/views/layout-alternative.erb +2 -0
  48. data/spec/views/layout-yield.erb +3 -0
  49. data/spec/views/layout.erb +2 -0
  50. data/spec/views/layout.str +2 -0
  51. metadata +57 -2
@@ -0,0 +1,215 @@
1
+ class Roda
2
+ module RodaPlugins
3
+ # The caching plugin adds methods related to HTTP caching.
4
+ #
5
+ # For proper caching, you should use either the +last_modified+ or
6
+ # +etag+ request methods.
7
+ #
8
+ # r.get '/albums/:d' do |album_id|
9
+ # @album = Album[album_id]
10
+ # r.last_modified @album.updated_at
11
+ # view('album')
12
+ # end
13
+ #
14
+ # # or
15
+ #
16
+ # r.get '/albums/:d' do |album_id|
17
+ # @album = Album[album_id]
18
+ # r.etag @album.sha1
19
+ # view('album')
20
+ # end
21
+ #
22
+ # Both +last_modified+ or +etag+ will immediately halt processing
23
+ # if there have been no modifications since the last time the
24
+ # client requested the resource, assuming the client uses the
25
+ # appropriate HTTP 1.1 request headers.
26
+ #
27
+ # This plugin also includes the +cache_control+ and +expires+
28
+ # response methods. The +cache_control+ method sets the
29
+ # Cache-Control header using the given hash:
30
+ #
31
+ # response.cache_control :public=>true, :max_age=>60
32
+ # # Cache-Control: public, max-age=60
33
+ #
34
+ # The +expires+ method is similar, but in addition
35
+ # to setting the HTTP 1.1 Cache-Control header, it also sets
36
+ # the HTTP 1.0 Expires header:
37
+ #
38
+ # response.expires 60, :public=>true
39
+ # # Cache-Control: public, max-age=60
40
+ # # Expires: Mon, 29 Sep 2014 21:25:47 GMT
41
+ #
42
+ # The implementation was originally taken from Sinatra,
43
+ # which is also released under the MIT License:
44
+ #
45
+ # Copyright (c) 2007, 2008, 2009 Blake Mizerany
46
+ # Copyright (c) 2010, 2011, 2012, 2013, 2014 Konstantin Haase
47
+ #
48
+ # Permission is hereby granted, free of charge, to any person
49
+ # obtaining a copy of this software and associated documentation
50
+ # files (the "Software"), to deal in the Software without
51
+ # restriction, including without limitation the rights to use,
52
+ # copy, modify, merge, publish, distribute, sublicense, and/or sell
53
+ # copies of the Software, and to permit persons to whom the
54
+ # Software is furnished to do so, subject to the following
55
+ # conditions:
56
+ #
57
+ # The above copyright notice and this permission notice shall be
58
+ # included in all copies or substantial portions of the Software.
59
+ #
60
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
61
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
62
+ # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
63
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
64
+ # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
65
+ # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
66
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
67
+ # OTHER DEALINGS IN THE SOFTWARE.
68
+ module Caching
69
+ module RequestMethods
70
+ LAST_MODIFIED = 'Last-Modified'.freeze
71
+ HTTP_IF_NONE_MATCH = 'HTTP_IF_NONE_MATCH'.freeze
72
+ HTTP_IF_MATCH = 'HTTP_IF_MATCH'.freeze
73
+ HTTP_IF_MODIFIED_SINCE = 'HTTP_IF_MODIFIED_SINCE'.freeze
74
+ HTTP_IF_UNMODIFIED_SINCE = 'HTTP_IF_UNMODIFIED_SINCE'.freeze
75
+ ETAG = 'ETag'.freeze
76
+ STAR = '*'.freeze
77
+
78
+ # Set the last modified time of the resource using the Last-Modified header.
79
+ # The +time+ argument should be a Time instance.
80
+ #
81
+ # If the current request includes an If-Modified-Since header that is
82
+ # equal or later than the time specified, immediately returns a response
83
+ # with a 304 status.
84
+ #
85
+ # If the current request includes an If-Unmodified-Since header that is
86
+ # before than the time specified, immediately returns a response
87
+ # with a 412 status.
88
+ def last_modified(time)
89
+ return unless time
90
+ response[LAST_MODIFIED] = time.httpdate
91
+ e = env
92
+ return if e[HTTP_IF_NONE_MATCH]
93
+ status = response.status
94
+
95
+ if (!status || status == 200) && (ims = time_from_header(e[HTTP_IF_MODIFIED_SINCE])) && ims >= time.to_i
96
+ response.status = 304
97
+ halt
98
+ end
99
+
100
+ if (!status || (status >= 200 && status < 300) || status == 412) && (ius = time_from_header(e[HTTP_IF_UNMODIFIED_SINCE])) && ius < time.to_i
101
+ response.status = 412
102
+ halt
103
+ end
104
+ end
105
+
106
+ # Set the response entity tag using the ETag header.
107
+ #
108
+ # The +value+ argument is an identifier that uniquely
109
+ # identifies the current version of the resource.
110
+ # Options:
111
+ # :weak :: Use a weak cache validator (a strong cache validator is the default)
112
+ # :new_resource :: Whether this etag should match an etag of * (true for POST, false otherwise)
113
+ #
114
+ # When the current request includes an If-None-Match header with a
115
+ # matching etag, immediately returns a response with a 304 or 412 status,
116
+ # depending on the request method.
117
+ #
118
+ # When the current request includes an If-Match header with a
119
+ # etag that doesn't match, immediately returns a response with a 412 status.
120
+ def etag(value, opts={})
121
+ # Before touching this code, please double check RFC 2616 14.24 and 14.26.
122
+ weak = opts[:weak]
123
+ new_resource = opts.fetch(:new_resource){post?}
124
+
125
+ response[ETAG] = etag = "#{'W/' if weak}\"#{value}\""
126
+ status = response.status
127
+ e = env
128
+
129
+ if (!status || (status >= 200 && status < 300) || status == 304)
130
+ if etag_matches?(e[HTTP_IF_NONE_MATCH], etag, new_resource)
131
+ response.status = (request_method =~ /\AGET|HEAD|OPTIONS|TRACE\z/i ? 304 : 412)
132
+ halt
133
+ end
134
+
135
+ if ifm = e[HTTP_IF_MATCH]
136
+ unless etag_matches?(ifm, etag, new_resource)
137
+ response.status = 412
138
+ halt
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+ private
145
+
146
+ # Helper method checking if a ETag value list includes the current ETag.
147
+ def etag_matches?(list, etag, new_resource)
148
+ return unless list
149
+ return !new_resource if list == STAR
150
+ list.to_s.split(/\s*,\s*/).include?(etag)
151
+ end
152
+
153
+ # Helper method parsing a time value from an HTTP header, returning the
154
+ # time as an integer.
155
+ def time_from_header(t)
156
+ Time.httpdate(t).to_i if t
157
+ rescue ArgumentError
158
+ end
159
+ end
160
+
161
+ module ResponseMethods
162
+ UNDERSCORE = '_'.freeze
163
+ DASH = '-'.freeze
164
+ COMMA = ', '.freeze
165
+ CACHE_CONTROL = 'Cache-Control'.freeze
166
+ EXPIRES = 'Expires'.freeze
167
+ CONTENT_TYPE = 'Content-Type'.freeze
168
+ CONTENT_LENGTH = 'Content-Length'.freeze
169
+
170
+ # Specify response freshness policy for using the Cache-Control header.
171
+ # Options can can any non-value directives (:public, :private, :no_cache,
172
+ # :no_store, :must_revalidate, :proxy_revalidate), with true as the value.
173
+ # Options can also contain value directives (:max_age, :s_maxage).
174
+ #
175
+ # response.cache_control :public=>true, :max_age => 60
176
+ # # => Cache-Control: public, max-age=60
177
+ #
178
+ # See RFC 2616 / 14.9 for more on standard cache control directives:
179
+ # http://tools.ietf.org/html/rfc2616#section-14.9.1
180
+ def cache_control(opts)
181
+ values = []
182
+ opts.each do |k, v|
183
+ next unless v
184
+ k = k.to_s.tr(UNDERSCORE, DASH)
185
+ values << (v == true ? k : "#{k}=#{v}")
186
+ end
187
+
188
+ self[CACHE_CONTROL] = values.join(COMMA) unless values.empty?
189
+ end
190
+
191
+ # Set Cache-Control header with the max_age given. max_age should
192
+ # be an integer number of seconds that the current request should be
193
+ # cached for. Also sets the Expires header, useful if you have
194
+ # HTTP 1.0 clients (Cache-Control is an HTTP 1.1 header).
195
+ def expires(max_age, opts={})
196
+ cache_control(opts.merge(:max_age=>max_age))
197
+ self[EXPIRES] = (Time.now + max_age).httpdate
198
+ end
199
+
200
+ # Remove Content-Type and Content-Length for 304 responses.
201
+ def finish
202
+ a = super
203
+ if a[0] == 304
204
+ h = a[1]
205
+ h.delete(CONTENT_TYPE)
206
+ h.delete(CONTENT_LENGTH)
207
+ end
208
+ a
209
+ end
210
+ end
211
+ end
212
+
213
+ register_plugin(:caching, Caching)
214
+ end
215
+ end
@@ -0,0 +1,278 @@
1
+ class Roda
2
+ module RodaPlugins
3
+ # The chunked plugin allows you to stream responses to clients using
4
+ # Transfer-Encoding: chunked. This can significantly improve performance
5
+ # of page rendering on the client, as it flushes the headers and top part
6
+ # of the layout template (generally containing references to the stylesheet
7
+ # and javascript assets) before rendering the content template.
8
+ #
9
+ # This allows the client to fetch the assets while the template is still
10
+ # being rendered. Additionally, this plugin makes it easy to defer
11
+ # executing code required to render the content template until after
12
+ # the top part of the layout has been flushed, so the client can fetch the
13
+ # assets while the application is still doing the necessary processing in
14
+ # order to render the content template, such as retrieving values from a
15
+ # database.
16
+ #
17
+ # There are a couple disadvantages of streaming using chunked encoding.
18
+ # First is that the layout must be rendered before the content, so any state
19
+ # changes made in your content template will not affect the layout template.
20
+ # Second, error handling is reduced, since if an error occurs while
21
+ # rendering a template, a successful response code has already been sent.
22
+ #
23
+ # To use chunked encoding for a response, just call the chunked method
24
+ # instead of view:
25
+ #
26
+ # r.root do
27
+ # chunked(:index)
28
+ # end
29
+ #
30
+ # If you want to execute code after flushing the top part of the layout
31
+ # template, but before rendering the content template, pass a block to
32
+ # chunked:
33
+ #
34
+ # r.root do
35
+ # chunked(:index) do
36
+ # # expensive calculation here
37
+ # end
38
+ # end
39
+ #
40
+ # If you want to chunk all responses, pass the :chunk_by_default option
41
+ # when loading the plugin:
42
+ #
43
+ # plugin :chunked, :chunk_by_default => true
44
+ #
45
+ # then you can just use the normal view method:
46
+ #
47
+ # r.root do
48
+ # view(:index)
49
+ # end
50
+ #
51
+ # and it will chunk the response. Note that you still need to call
52
+ # chunked if you want to pass a block of code to be executed after flushing
53
+ # the layout and before rendering the content template. Also, before you
54
+ # enable chunking by default, you need to make sure that none of your
55
+ # content templates make state changes that affect the layout template.
56
+ # Additionally, make sure nowhere in your app are you doing any processing
57
+ # after the call to view.
58
+ #
59
+ # If you use :chunk_by_default, but want to turn off chunking for a view,
60
+ # call no_chunk!:
61
+ #
62
+ # r.root do
63
+ # no_chunk!
64
+ # view(:index)
65
+ # end
66
+ #
67
+ # Inside your layout or content templates, you can call the flush method
68
+ # to flush the current result of the template to the user, useful for
69
+ # streaming large datasets.
70
+ #
71
+ # <% (1..100).each do |i| %>
72
+ # <%= i %>
73
+ # <% sleep 0.1 %>
74
+ # <% flush %>
75
+ # <% end %>
76
+ #
77
+ # Note that you should not call flush from inside subtemplates of the
78
+ # content or layout templates, unless you are also calling flush directly
79
+ # before rendering the subtemplate, and also directly injecting the
80
+ # subtemplate into the current template without modification. So if you
81
+ # are using the above template code in a subtemplate, in your content
82
+ # template you should do:
83
+ #
84
+ # <% flush %><%= render(:subtemplate) %>
85
+ #
86
+ # If you want to use chunked encoding when rendering a template, but don't
87
+ # want to use a layout, pass the :layout=>false option to chunked.
88
+ #
89
+ # r.root do
90
+ # chunked(:index, :layout=>false)
91
+ # end
92
+ #
93
+ # In order to handle errors in chunked responses, you can override the
94
+ # handle_chunk_error method:
95
+ #
96
+ # def handle_chunk_error(e)
97
+ # env['rack.logger'].error(e)
98
+ # end
99
+ #
100
+ # It is possible to set @_out_buf to an error notification and call
101
+ # flush to output the message to the client inside handle_chunk_error.
102
+ #
103
+ # In order for chunking to work, you must make sure that no proxies between
104
+ # the application and the client buffer responses. Also, this
105
+ # plugin only works for HTTP/1.1 requests since Transfer-Encoding: chunked
106
+ # is not supported in HTTP/1.0. If an HTTP/1.0 request is submitted, this
107
+ # plugin will automatically fallback to the normal template rendering.
108
+ # Note that some proxies including nginx default to HTTP/1.0 even if the
109
+ # client supports HTTP/1.1. For nginx, set the proxy_http_version to 1.1.
110
+ #
111
+ # If you are using nginx and have it set to buffer proxy responses by
112
+ # default, you can turn this off on a per response basis using the
113
+ # X-Accel-Buffering header. To set this header or similar headers for
114
+ # all chunked responses, pass a :headers option when loading the plugin:
115
+ #
116
+ # plugin :chunked, :headers=>{'X-Accel-Buffering'=>'no'}
117
+ #
118
+ # The chunked plugin requires the render plugin, and only works for
119
+ # template engines that store their template output variable in
120
+ # @_out_buf. Also, it only works if the content template is directly
121
+ # injected into the layout template without modification.
122
+ #
123
+ # If using the chunked plugin with the flash plugin, make sure you
124
+ # call the flash method early in your route block. If the flash
125
+ # method is not called until template rendering, the flash may not be
126
+ # rotated.
127
+ module Chunked
128
+ HTTP_VERSION = 'HTTP_VERSION'.freeze
129
+ HTTP11 = "HTTP/1.1".freeze
130
+ TRANSFER_ENCODING = 'Transfer-Encoding'.freeze
131
+ CHUNKED = 'chunked'.freeze
132
+ OPTS = {}.freeze
133
+
134
+ # Depend on the render plugin
135
+ def self.load_dependencies(app, opts=OPTS)
136
+ app.plugin :render
137
+ end
138
+
139
+ # Set plugin specific options. Options:
140
+ # :chunk_by_default :: chunk all calls to view by default
141
+ # :headers :: Set default additional headers to use when calling view
142
+ def self.configure(app, opts=OPTS)
143
+ app.opts[:chunk_by_default] = opts[:chunk_by_default]
144
+ app.opts[:chunk_headers] = opts[:headers]
145
+ end
146
+
147
+ # Rack response body instance for chunked responses
148
+ class Body
149
+ CHUNK_SIZE = "%x\r\n".freeze
150
+ CRLF = "\r\n".freeze
151
+ FINISH = "0\r\n\r\n".freeze
152
+
153
+ # Save the scope of the current request handling.
154
+ def initialize(scope)
155
+ @scope = scope
156
+ end
157
+
158
+ # For each response chunk yielded by the scope,
159
+ # yield it it to the caller in chunked format, starting
160
+ # with the size of the request in ASCII hex format, then
161
+ # the chunk. After all chunks have been yielded, yield
162
+ # a 0 sized chunk to finish the response.
163
+ def each
164
+ @scope.each_chunk do |chunk|
165
+ next if !chunk || chunk.empty?
166
+ yield(CHUNK_SIZE % chunk.bytesize)
167
+ yield(chunk)
168
+ yield(CRLF)
169
+ end
170
+ ensure
171
+ yield(FINISH)
172
+ end
173
+ end
174
+
175
+ module InstanceMethods
176
+ # Disable chunking for the current request. Mostly useful when
177
+ # chunking is turned on by default.
178
+ def no_chunk!
179
+ @_chunked = false
180
+ end
181
+
182
+ # If chunking by default, call chunked if it hasn't yet been
183
+ # called and chunking is not specifically disabled.
184
+ def view(*a)
185
+ if opts[:chunk_by_default] && !defined?(@_chunked)
186
+ chunked(*a)
187
+ else
188
+ super
189
+ end
190
+ end
191
+
192
+ # Render a response to the user in chunks. See Chunked for
193
+ # an overview.
194
+ def chunked(template, opts=OPTS, &block)
195
+ unless defined?(@_chunked)
196
+ @_chunked = env[HTTP_VERSION] == HTTP11
197
+ end
198
+
199
+ unless @_chunked
200
+ # If chunking is disabled, do a normal rendering of the view.
201
+ yield if block
202
+ return view(template, opts)
203
+ end
204
+
205
+ if template.is_a?(Hash)
206
+ if opts.empty?
207
+ opts = template
208
+ else
209
+ opts = opts.merge(template)
210
+ end
211
+ end
212
+
213
+ # Hack so that the arguments don't need to be passed
214
+ # through the response and body objects.
215
+ @_each_chunk_args = [template, opts, block]
216
+
217
+ res = response
218
+ headers = res.headers
219
+ if chunk_headers = self.opts[:chunk_headers]
220
+ headers.merge!(chunk_headers)
221
+ end
222
+ headers[TRANSFER_ENCODING] = CHUNKED
223
+
224
+ throw :halt, res.finish_with_body(Body.new(self))
225
+ end
226
+
227
+ # Yield each chunk of the template rendering separately.
228
+ def each_chunk
229
+ response.body.each{|s| yield s}
230
+
231
+ template, opts, block = @_each_chunk_args
232
+
233
+ # Use a lambda for the flusher, so that a call to flush
234
+ # by a template can result in this method yielding a chunk
235
+ # of the response.
236
+ @_flusher = lambda do
237
+ yield @_out_buf
238
+ @_out_buf = ''
239
+ end
240
+
241
+ if layout = opts.fetch(:layout, render_opts[:layout])
242
+ if layout_opts = opts[:layout_opts]
243
+ layout_opts = render_opts[:layout_opts].merge(layout_opts)
244
+ end
245
+
246
+ @_out_buf = render(layout, layout_opts||OPTS) do
247
+ flush
248
+ block.call if block
249
+ yield opts[:content] || render(template, opts)
250
+ nil
251
+ end
252
+ else
253
+ yield if block
254
+ yield view(template, opts)
255
+ end
256
+
257
+ flush
258
+ rescue => e
259
+ handle_chunk_error(e)
260
+ end
261
+
262
+ # By default, raise the exception.
263
+ def handle_chunk_error(e)
264
+ raise e
265
+ end
266
+
267
+ # Call the flusher if one is defined. If one is not defined, this
268
+ # is a no-op, so flush can be used inside views without breaking
269
+ # things if chunking is not used.
270
+ def flush
271
+ @_flusher.call if @_flusher
272
+ end
273
+ end
274
+ end
275
+
276
+ register_plugin(:chunked, Chunked)
277
+ end
278
+ end