roda 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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