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,112 @@
1
+ require 'net/smtp'
2
+
3
+ class Roda
4
+ module RodaPlugins
5
+ # The error_email plugin adds an +error_email+ instance method that
6
+ # send an email related to the exception. This is most useful if you are
7
+ # also using the error_handler plugin:
8
+ #
9
+ # plugin :error_email, :to=>'to@example.com', :from=>'from@example.com'
10
+ # plugin :error_handler do |e|
11
+ # error_email(e)
12
+ # 'Internal Server Error'
13
+ # end
14
+ #
15
+ # Options:
16
+ #
17
+ # :from :: The From address to use in the email (required)
18
+ # :headers :: A hash of additional headers to use in the email (default: empty hash)
19
+ # :host :: The SMTP server to use to send the email (default: localhost)
20
+ # :prefix :: A prefix to use in the email's subject line (default: no prefix)
21
+ # :to :: The To address to use in the email (required)
22
+ #
23
+ # The subject of the error email shows the exception class and message.
24
+ # The body of the error email shows the backtrace of the error and the
25
+ # request environment, as well the request params and session variables (if any).
26
+ #
27
+ # Note that emailing on every error as shown above is only appropriate
28
+ # for low traffic web applications. For high traffic web applications,
29
+ # use an error reporting service instead of this plugin.
30
+ module ErrorEmail
31
+ DEFAULTS = {
32
+ :headers=>{},
33
+ :host=>'localhost',
34
+ # :nocov:
35
+ :emailer=>lambda{|h| Net::SMTP.start(h[:host]){|s| s.send_message(h[:message], h[:from], h[:to])}},
36
+ # :nocov:
37
+ :default_headers=>lambda do |h, e|
38
+ {'From'=>h[:from], 'To'=>h[:to], 'Subject'=>"#{h[:prefix]}#{e.class}: #{e.message}"}
39
+ end,
40
+ :body=>lambda do |s, e|
41
+ format = lambda{|h| h.map{|k, v| "#{k.inspect} => #{v.inspect}"}.sort.join("\n")}
42
+
43
+ message = <<END
44
+ Path: #{s.request.full_path_info}
45
+
46
+ Backtrace:
47
+
48
+ #{e.backtrace.join("\n")}
49
+
50
+ ENV:
51
+
52
+ #{format[s.env]}
53
+ END
54
+ unless s.request.params.empty?
55
+ message << <<END
56
+
57
+ Params:
58
+
59
+ #{format[s.request.params]}
60
+ END
61
+ end
62
+
63
+ if s.env['rack.session']
64
+ message << <<END
65
+
66
+ Session:
67
+
68
+ #{format[s.session]}
69
+ END
70
+ end
71
+
72
+ message
73
+ end
74
+ }
75
+
76
+ # Set default opts for plugin. See ErrorEmail module RDoc for options.
77
+ def self.configure(app, opts={})
78
+ email_opts = app.opts[:error_email] ||= DEFAULTS
79
+ email_opts = email_opts.merge(opts)
80
+ unless email_opts[:to] && email_opts[:from]
81
+ raise RodaError, "must provide :to and :from options to error_email plugin"
82
+ end
83
+ app.opts[:error_email] = email_opts
84
+ end
85
+
86
+ module ClassMethods
87
+ # Dup the error email opts in the subclass so changes to the subclass do not affect
88
+ # the superclass.
89
+ def inherited(subclass)
90
+ super
91
+ subclass.opts[:error_email] = subclass.opts[:error_email].dup
92
+ subclass.opts[:error_email][:headers] = subclass.opts[:error_email][:headers].dup
93
+ end
94
+ end
95
+
96
+ module InstanceMethods
97
+ # Send an email for the given error.
98
+ def error_email(e)
99
+ email_opts = self.class.opts[:error_email].dup
100
+ headers = email_opts[:default_headers].call(email_opts, e)
101
+ headers = headers.merge(email_opts[:headers])
102
+ headers = headers.map{|k,v| "#{k}: #{v.gsub(/\r?\n/m, "\r\n ")}"}.sort.join("\r\n")
103
+ body = email_opts[:body].call(self, e)
104
+ email_opts[:message] = "#{headers}\r\n\r\n#{body}"
105
+ email_opts[:emailer].call(email_opts)
106
+ end
107
+ end
108
+ end
109
+
110
+ register_plugin(:error_email, ErrorEmail)
111
+ end
112
+ end
@@ -26,6 +26,9 @@ class Roda
26
26
  # end
27
27
  # end
28
28
  module Flash
29
+ # The internal session key used to store the flash.
30
+ KEY = :_flash
31
+
29
32
  # Simple flash hash, where assiging to the hash updates the flash
30
33
  # used in the following request.
31
34
  class FlashHash < DelegateClass(Hash)
@@ -78,9 +81,6 @@ class Roda
78
81
  end
79
82
 
80
83
  module InstanceMethods
81
- # The internal session key used to store the flash.
82
- KEY = :_flash
83
-
84
84
  # Access the flash hash for the current request, loading
85
85
  # it from the session if it is not already loaded.
86
86
  def flash
@@ -30,7 +30,7 @@ class Roda
30
30
  # handle cases where before hooks are added after the route block.
31
31
  module Hooks
32
32
  def self.configure(app)
33
- @app.instance_exec do
33
+ app.instance_exec do
34
34
  @after ||= nil
35
35
  @before ||= nil
36
36
  end
@@ -30,9 +30,9 @@ class Roda
30
30
  def indifferent_params(params)
31
31
  case params
32
32
  when Hash
33
- h = Hash.new{|h, k| h[k.to_s] if Symbol === k}
34
- params.each{|k, v| h[k] = indifferent_params(v)}
35
- h
33
+ hash = Hash.new{|h, k| h[k.to_s] if Symbol === k}
34
+ params.each{|k, v| hash[k] = indifferent_params(v)}
35
+ hash
36
36
  when Array
37
37
  params.map{|x| indifferent_params(x)}
38
38
  else
@@ -62,14 +62,9 @@ class Roda
62
62
  end
63
63
 
64
64
  module ClassMethods
65
- # If an argument is given, this is a middleware app, so create a Forwarder.
66
- # Otherwise, this is a usual instance creation, so call super.
67
- def new(app=nil)
68
- if app
69
- Forwarder.new(self, app)
70
- else
71
- super()
72
- end
65
+ # Create a Forwarder instead of a new instance.
66
+ def new(app)
67
+ Forwarder.new(self, app)
73
68
  end
74
69
 
75
70
  # Override the route block so that if no route matches, we throw so
@@ -51,35 +51,113 @@ class Roda
51
51
  # r.multi_route do
52
52
  # "default body"
53
53
  # end
54
+ #
55
+ # If a block is not provided to multi_route, the return value of the named
56
+ # route block will be used.
57
+ #
58
+ # == Routing Files
59
+ #
60
+ # The convention when using the multi_route plugin is to have a single
61
+ # named route per file, and these routing files should be stored in
62
+ # a routes subdirectory in your application. So for the above example, you
63
+ # would use the following files:
64
+ #
65
+ # routes/bar.rb
66
+ # routes/foo.rb
67
+ #
68
+ # == Namespace Support
69
+ #
70
+ # The multi_route plugin also has support for namespaces, allowing you to
71
+ # use r.multi_route at multiple levels in your routing tree. Example:
72
+ #
73
+ # route('foo') do |r|
74
+ # r.multi_route('foo')
75
+ # end
76
+ #
77
+ # route('bar') do |r|
78
+ # r.multi_route('bar')
79
+ # end
80
+ #
81
+ # route('baz', 'foo') do |r|
82
+ # # handles /foo/baz prefix
83
+ # end
84
+ #
85
+ # route('quux', 'foo') do |r|
86
+ # # handles /foo/quux prefix
87
+ # end
88
+ #
89
+ # route('baz', 'bar') do |r|
90
+ # # handles /bar/baz prefix
91
+ # end
92
+ #
93
+ # route('quux', 'bar') do |r|
94
+ # # handles /bar/quux prefix
95
+ # end
96
+ #
97
+ # route do |r|
98
+ # r.multi_route
99
+ #
100
+ # # or
101
+ #
102
+ # r.on "foo" do
103
+ # r.on("baz"){r.route("baz", "foo")}
104
+ # r.on("quux"){r.route("quux", "foo")}
105
+ # end
106
+ #
107
+ # r.on "bar" do
108
+ # r.on("baz"){r.route("baz", "bar")}
109
+ # r.on("quux"){r.route("quux", "bar")}
110
+ # end
111
+ # end
112
+ #
113
+ # === Routing Files
114
+ #
115
+ # The convention when using namespaces with the multi_route plugin is to
116
+ # store the routing files in subdirectories per namespace. So for the
117
+ # above example, you would have the following routing files:
118
+ #
119
+ # routes/bar.rb
120
+ # routes/bar/baz.rb
121
+ # routes/bar/quux.rb
122
+ # routes/foo.rb
123
+ # routes/foo/baz.rb
124
+ # routes/foo/quux.rb
54
125
  module MultiRoute
55
126
  # Initialize storage for the named routes.
56
127
  def self.configure(app)
57
- app.instance_exec{@named_routes ||= {}}
128
+ app.instance_exec do
129
+ @namespaced_routes ||= {}
130
+ app::RodaRequest.instance_variable_set(:@namespaced_route_regexps, {})
131
+ end
58
132
  end
59
133
 
60
134
  module ClassMethods
61
135
  # Copy the named routes into the subclass when inheriting.
62
136
  def inherited(subclass)
63
137
  super
64
- subclass.instance_variable_set(:@named_routes, @named_routes.dup)
138
+ nsr = subclass.instance_variable_set(:@namespaced_routes, {})
139
+ @namespaced_routes.each{|k, v| nsr[k] = v.dup}
140
+ subclass::RodaRequest.instance_variable_set(:@namespaced_route_regexps, {})
65
141
  end
66
142
 
67
- # An names for the currently stored named routes
68
- def named_routes
69
- @named_routes.keys
143
+ # The names for the currently stored named routes
144
+ def named_routes(namespace=nil)
145
+ @namespaced_routes[namespace].keys
70
146
  end
71
147
 
72
148
  # Return the named route with the given name.
73
- def named_route(name)
74
- @named_routes[name]
149
+ def named_route(name, namespace=nil)
150
+ @namespaced_routes[namespace][name]
75
151
  end
76
152
 
77
- # If the given route has a named, treat it as a named route and
153
+ # If the given route has a name, treat it as a named route and
78
154
  # store the route block. Otherwise, this is the main route, so
79
155
  # call super.
80
- def route(name=nil, &block)
156
+ def route(name=nil, namespace=nil, &block)
81
157
  if name
82
- @named_routes[name] = block
158
+ @namespaced_routes[namespace] ||= {}
159
+ @namespaced_routes[namespace][name] = block
160
+ self::RodaRequest.clear_named_route_regexp!(namespace)
83
161
  else
84
162
  super(&block)
85
163
  end
@@ -87,9 +165,19 @@ class Roda
87
165
  end
88
166
 
89
167
  module RequestClassMethods
168
+ # Clear cached regexp for named routes, it will be regenerated
169
+ # the next time it is needed.
170
+ #
171
+ # This shouldn't be an issue in production applications, but
172
+ # during development it's useful to support new named routes
173
+ # being added while the application is running.
174
+ def clear_named_route_regexp!(namespace=nil)
175
+ @namespaced_route_regexps.delete(namespace)
176
+ end
177
+
90
178
  # A regexp matching any of the current named routes.
91
- def named_route_regexp
92
- @named_route_regexp ||= /(#{Regexp.union(roda_class.named_routes)})/
179
+ def named_route_regexp(namespace=nil)
180
+ @namespaced_route_regexps[namespace] ||= /(#{Regexp.union(roda_class.named_routes(namespace).select{|s| s.is_a?(String)}.sort.reverse)})/
93
181
  end
94
182
  end
95
183
 
@@ -98,16 +186,20 @@ class Roda
98
186
  # named routes. If so, call that named route. If not, do nothing.
99
187
  # If the named route does not handle the request, and a block
100
188
  # is given, yield to the block.
101
- def multi_route
102
- on self.class.named_route_regexp do |section|
103
- route(section)
104
- yield if block_given?
189
+ def multi_route(namespace=nil)
190
+ on self.class.named_route_regexp(namespace) do |section|
191
+ r = route(section, namespace)
192
+ if block_given?
193
+ yield
194
+ else
195
+ r
196
+ end
105
197
  end
106
198
  end
107
199
 
108
200
  # Dispatch to the named route with the given name.
109
- def route(name)
110
- scope.instance_exec(self, &self.class.roda_class.named_route(name))
201
+ def route(name, namespace=nil)
202
+ scope.instance_exec(self, &self.class.roda_class.named_route(name, namespace))
111
203
  end
112
204
  end
113
205
  end
@@ -84,13 +84,13 @@ class Roda
84
84
  mod.class_eval(<<-END, __FILE__, __LINE__+1)
85
85
  def #{verb}(*args, &block)
86
86
  if args.empty?
87
- @_is_verbs << "#{verb.to_s.upcase}" if @_is_verbs
87
+ @_is_verbs << "#{verb.to_s.upcase}" if defined?(@_is_verbs)
88
88
  always(&block) if #{verb == :get ? :is_get : verb}?
89
89
  else
90
90
  args << ::Roda::RodaPlugins::Base::RequestMethods::TERM
91
- if_match(args) do |*args|
91
+ if_match(args) do |*a|
92
92
  if #{verb == :get ? :is_get : verb}?
93
- block_result(yield(*args))
93
+ block_result(yield(*a))
94
94
  throw :halt, response.finish
95
95
  end
96
96
  response.status = 405
@@ -0,0 +1,38 @@
1
+ class Roda
2
+ module RodaPlugins
3
+ # The path plugin adds support for named paths. Using the +path+ class method, you can
4
+ # easily create <tt>*_path</tt> instance methods for each named path. Those instance
5
+ # methods can then be called if you need to get the path for a form action, link,
6
+ # redirect, or anything else. Example:
7
+ #
8
+ # plugin :path
9
+ # path :foo, '/foo'
10
+ # path :bar do |bar|
11
+ # "/bar/#{bar.id}"
12
+ # end
13
+ #
14
+ # route do |r|
15
+ # r.post 'bar' do
16
+ # bar = Bar.create(r.params['bar'])
17
+ # r.redirect bar_path(bar)
18
+ # end
19
+ # end
20
+ module Path
21
+ module ClassMethods
22
+ def path(name, path=nil, &block)
23
+ raise RodaError, "cannot provide both path and block to Roda.path" if path && block
24
+ raise RodaError, "must provide either path or block to Roda.path" unless path || block
25
+
26
+ if path
27
+ path = path.dup.freeze
28
+ block = lambda{path}
29
+ end
30
+
31
+ define_method("#{name}_path", &block)
32
+ end
33
+ end
34
+ end
35
+
36
+ register_plugin(:path, Path)
37
+ end
38
+ end
@@ -18,20 +18,18 @@ class Roda
18
18
  # end
19
19
  # end
20
20
  #
21
- # You can provide options to the plugin method, or later by modifying
22
- # +render_opts+.
21
+ # You can provide options to the plugin method:
23
22
  #
24
- # plugin :render, :engine=>'haml'
25
- #
26
- # render_opts[:views] = 'admin_views'
23
+ # plugin :render, :engine=>'haml', :views=>'admin_views'
27
24
  #
28
25
  # The following options are supported:
29
26
  #
30
27
  # :cache :: nil/false to not cache templates (useful for development), defaults
31
28
  # to true to automatically use the default template cache.
32
29
  # :engine :: The tilt engine to use for rendering, defaults to 'erb'.
33
- # :escape :: Use Roda's Erubis escaping support, which handles postfix
34
- # conditions inside <%= %> tags.
30
+ # :escape :: Use Roda's Erubis escaping support, which makes <%= %> escape output,
31
+ # <%== %> not escape output, and handles postfix conditions inside
32
+ # <%= %> tags.
35
33
  # :ext :: The file extension to assume for view files, defaults to the :engine
36
34
  # option.
37
35
  # :layout :: The base name of the layout file, defaults to 'layout'.
@@ -69,23 +67,25 @@ class Roda
69
67
  # If you pass a hash as the first argument to +view+ or +render+, it should
70
68
  # have either +:inline+ or +:path+ as one of the keys.
71
69
  module Render
72
- def self.load_dependencies(app, opts={})
70
+ OPTS={}.freeze
71
+
72
+ def self.load_dependencies(app, opts=OPTS)
73
73
  if opts[:escape]
74
74
  app.plugin :_erubis_escaping
75
75
  end
76
76
  end
77
77
 
78
78
  # Setup default rendering options. See Render for details.
79
- def self.configure(app, opts={})
79
+ def self.configure(app, opts=OPTS)
80
80
  if app.opts[:render]
81
81
  app.opts[:render].merge!(opts)
82
- else
82
+ else
83
83
  app.opts[:render] = opts.dup
84
84
  end
85
85
 
86
86
  opts = app.opts[:render]
87
87
  opts[:engine] ||= "erb"
88
- opts[:ext] = nil unless opts.has_key?(:ext)
88
+ opts[:ext] = nil unless opts.has_key?(:ext)
89
89
  opts[:views] ||= File.expand_path("views", Dir.pwd)
90
90
  opts[:layout] = "layout" unless opts.has_key?(:layout)
91
91
  opts[:layout_opts] ||= (opts[:layout_opts] || {}).dup
@@ -120,7 +120,7 @@ class Roda
120
120
 
121
121
  module InstanceMethods
122
122
  # Render the given template. See Render for details.
123
- def render(template, opts = {}, &block)
123
+ def render(template, opts = OPTS, &block)
124
124
  if template.is_a?(Hash)
125
125
  if opts.empty?
126
126
  opts = template
@@ -143,10 +143,12 @@ class Roda
143
143
 
144
144
  cached_template(path) do
145
145
  template_class.new(path, 1, render_opts[:opts].merge(opts), &template_block)
146
- end.render(self, opts[:locals], &block)
146
+ end.render(self, (opts[:locals]||OPTS), &block)
147
147
  end
148
148
 
149
- # Return the render options for the instance's class.
149
+ # Return the render options for the instance's class. While this
150
+ # is not currently frozen, it may be frozen in a future version,
151
+ # so you should not attempt to modify it.
150
152
  def render_opts
151
153
  self.class.render_opts
152
154
  end
@@ -154,7 +156,7 @@ class Roda
154
156
  # Render the given template. If there is a default layout
155
157
  # for the class, take the result of the template rendering
156
158
  # and render it inside the layout. See Render for details.
157
- def view(template, opts={})
159
+ def view(template, opts=OPTS)
158
160
  if template.is_a?(Hash)
159
161
  if opts.empty?
160
162
  opts = template
@@ -170,7 +172,7 @@ class Roda
170
172
  layout_opts = render_opts[:layout_opts].merge(layout_opts)
171
173
  end
172
174
 
173
- content = render(layout, layout_opts||{}){content}
175
+ content = render(layout, layout_opts||OPTS){content}
174
176
  end
175
177
 
176
178
  content