roda 1.1.0 → 1.2.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG +70 -0
  3. data/README.rdoc +261 -302
  4. data/Rakefile +1 -1
  5. data/doc/release_notes/1.2.0.txt +406 -0
  6. data/lib/roda.rb +206 -124
  7. data/lib/roda/plugins/all_verbs.rb +11 -10
  8. data/lib/roda/plugins/assets.rb +5 -5
  9. data/lib/roda/plugins/backtracking_array.rb +12 -5
  10. data/lib/roda/plugins/caching.rb +10 -8
  11. data/lib/roda/plugins/class_level_routing.rb +94 -0
  12. data/lib/roda/plugins/content_for.rb +6 -0
  13. data/lib/roda/plugins/default_headers.rb +4 -11
  14. data/lib/roda/plugins/delay_build.rb +42 -0
  15. data/lib/roda/plugins/delegate.rb +64 -0
  16. data/lib/roda/plugins/drop_body.rb +33 -0
  17. data/lib/roda/plugins/empty_root.rb +48 -0
  18. data/lib/roda/plugins/environments.rb +68 -0
  19. data/lib/roda/plugins/error_email.rb +1 -2
  20. data/lib/roda/plugins/error_handler.rb +1 -1
  21. data/lib/roda/plugins/halt.rb +7 -5
  22. data/lib/roda/plugins/head.rb +4 -2
  23. data/lib/roda/plugins/header_matchers.rb +17 -9
  24. data/lib/roda/plugins/hooks.rb +16 -32
  25. data/lib/roda/plugins/json.rb +4 -10
  26. data/lib/roda/plugins/mailer.rb +233 -0
  27. data/lib/roda/plugins/match_affix.rb +48 -0
  28. data/lib/roda/plugins/multi_route.rb +9 -11
  29. data/lib/roda/plugins/multi_run.rb +81 -0
  30. data/lib/roda/plugins/named_templates.rb +93 -0
  31. data/lib/roda/plugins/not_allowed.rb +43 -48
  32. data/lib/roda/plugins/path.rb +63 -2
  33. data/lib/roda/plugins/render.rb +79 -48
  34. data/lib/roda/plugins/render_each.rb +6 -0
  35. data/lib/roda/plugins/sinatra_helpers.rb +523 -0
  36. data/lib/roda/plugins/slash_path_empty.rb +25 -0
  37. data/lib/roda/plugins/static_path_info.rb +64 -0
  38. data/lib/roda/plugins/streaming.rb +1 -1
  39. data/lib/roda/plugins/view_subdirs.rb +12 -8
  40. data/lib/roda/version.rb +1 -1
  41. data/spec/integration_spec.rb +33 -0
  42. data/spec/plugin/backtracking_array_spec.rb +24 -18
  43. data/spec/plugin/class_level_routing_spec.rb +138 -0
  44. data/spec/plugin/delay_build_spec.rb +23 -0
  45. data/spec/plugin/delegate_spec.rb +20 -0
  46. data/spec/plugin/drop_body_spec.rb +20 -0
  47. data/spec/plugin/empty_root_spec.rb +14 -0
  48. data/spec/plugin/environments_spec.rb +31 -0
  49. data/spec/plugin/h_spec.rb +1 -3
  50. data/spec/plugin/header_matchers_spec.rb +14 -0
  51. data/spec/plugin/hooks_spec.rb +3 -5
  52. data/spec/plugin/mailer_spec.rb +191 -0
  53. data/spec/plugin/match_affix_spec.rb +22 -0
  54. data/spec/plugin/multi_run_spec.rb +31 -0
  55. data/spec/plugin/named_templates_spec.rb +65 -0
  56. data/spec/plugin/path_spec.rb +66 -2
  57. data/spec/plugin/render_spec.rb +46 -1
  58. data/spec/plugin/sinatra_helpers_spec.rb +534 -0
  59. data/spec/plugin/slash_path_empty_spec.rb +22 -0
  60. data/spec/plugin/static_path_info_spec.rb +50 -0
  61. data/spec/request_spec.rb +23 -0
  62. data/spec/response_spec.rb +12 -1
  63. metadata +48 -6
@@ -0,0 +1,68 @@
1
+ class Roda
2
+ module RodaPlugins
3
+ # The environments plugin adds a environment class accessor to get
4
+ # the environment for the application, 3 predicate class methods
5
+ # to check for the current environment (development?, test? and
6
+ # production?), and a class configure method that takes environment(s)
7
+ # and yields to the block if the given environment(s) match the
8
+ # current environment.
9
+ #
10
+ # The default environment for the application is based on
11
+ # <tt>ENV['RACK_ENV']</tt>.
12
+ #
13
+ # Example:
14
+ #
15
+ # class Roda
16
+ # plugin :environments
17
+ #
18
+ # environment # => :development
19
+ # development? # => true
20
+ # test? # => false
21
+ # production? # => false
22
+ #
23
+ # # Set the environment for the application
24
+ # self.environment = :test
25
+ # test? # => true
26
+ #
27
+ # configure do
28
+ # # called, as no environments given
29
+ # end
30
+ #
31
+ # configure :development, :production do
32
+ # # not called, as no environments match
33
+ # end
34
+ #
35
+ # configure :test do
36
+ # # called, as environment given matches current environment
37
+ # end
38
+ # end
39
+ module Environments
40
+ # Set the environment to use for the app. Default to ENV['RACK_ENV']
41
+ # if no environment is given. If ENV['RACK_ENV'] is not set and
42
+ # no environment is given, assume the development environment.
43
+ def self.configure(app, env=ENV["RACK_ENV"])
44
+ app.environment = (env || 'development').to_sym
45
+ end
46
+
47
+ module ClassMethods
48
+ # The current environment for the application, which should be stored
49
+ # as a symbol.
50
+ attr_accessor :environment
51
+
52
+ # If no environments are given or one of the given environments
53
+ # matches the current environment, yield the receiver to the block.
54
+ def configure(*envs)
55
+ if envs.empty? || envs.any?{|s| s == environment}
56
+ yield self
57
+ end
58
+ end
59
+
60
+ [:development, :test, :production].each do |env|
61
+ define_method("#{env}?"){environment == env}
62
+ end
63
+ end
64
+ end
65
+
66
+ register_plugin(:environments, Environments)
67
+ end
68
+ end
@@ -41,7 +41,7 @@ class Roda
41
41
  format = lambda{|h| h.map{|k, v| "#{k.inspect} => #{v.inspect}"}.sort.join("\n")}
42
42
 
43
43
  message = <<END
44
- Path: #{s.request.full_path_info}
44
+ Path: #{s.request.path}
45
45
 
46
46
  Backtrace:
47
47
 
@@ -88,7 +88,6 @@ END
88
88
  # the superclass.
89
89
  def inherited(subclass)
90
90
  super
91
- subclass.opts[:error_email] = subclass.opts[:error_email].dup
92
91
  subclass.opts[:error_email][:headers] = subclass.opts[:error_email][:headers].dup
93
92
  end
94
93
  end
@@ -51,7 +51,7 @@ class Roda
51
51
  def _route
52
52
  super
53
53
  rescue => e
54
- response.status = 500
54
+ @_response.status = 500
55
55
  super{handle_error(e)}
56
56
  end
57
57
 
@@ -53,12 +53,14 @@ class Roda
53
53
  raise Roda::RodaError, "singular argument to #halt must be Integer, String, or Array"
54
54
  end
55
55
  when 2
56
- response.status = res[0]
57
- response.write res[1]
56
+ resp = response
57
+ resp.status = res[0]
58
+ resp.write res[1]
58
59
  when 3
59
- response.status = res[0]
60
- response.headers.merge!(res[1])
61
- response.write res[2]
60
+ resp = response
61
+ resp.status = res[0]
62
+ resp.headers.merge!(res[1])
63
+ resp.write res[2]
62
64
  else
63
65
  raise Roda::RodaError, "too many arguments given to #halt (accepts 0-3, received #{res.length})"
64
66
  end
@@ -23,13 +23,15 @@ class Roda
23
23
  # HEAD requests for +/+, +/a+, and +/b+ will all return 200 status
24
24
  # with an empty body.
25
25
  module Head
26
+ EMPTY_ARRAY = [].freeze
27
+
26
28
  module InstanceMethods
27
29
  # Always use an empty response body for head requests, with a
28
30
  # content length of 0.
29
31
  def call(*)
30
32
  res = super
31
- if request.head?
32
- res[2] = []
33
+ if @_request.head?
34
+ res[2] = EMPTY_ARRAY
33
35
  end
34
36
  res
35
37
  end
@@ -8,23 +8,23 @@ class Roda
8
8
  # It adds a +:header+ matcher for matching on arbitrary headers, which matches
9
9
  # if the header is present:
10
10
  #
11
- # route do |r|
12
- # r.on :header=>'X-App-Token' do
13
- # end
11
+ # r.on :header=>'X-App-Token' do
14
12
  # end
15
13
  #
16
14
  # It adds a +:host+ matcher for matching by the host of the request:
17
15
  #
18
- # route do |r|
19
- # r.on :host=>'foo.example.com' do
20
- # end
16
+ # r.on :host=>'foo.example.com' do
17
+ # end
18
+ #
19
+ # It adds a +:user_agent+ matcher for matching on a user agent patterns, which
20
+ # yields the regexp captures to the block:
21
+ #
22
+ # r.on :user_agent=>/Chrome\/([.\d]+)/ do |chrome_version|
21
23
  # end
22
24
  #
23
25
  # It adds an +:accept+ matcher for matching based on the Accept header:
24
26
  #
25
- # route do |r|
26
- # r.on :accept=>'text/csv' do
27
- # end
27
+ # r.on :accept=>'text/csv' do
28
28
  # end
29
29
  #
30
30
  # Note that the accept matcher is very simple and cannot handle wildcards,
@@ -49,6 +49,14 @@ class Roda
49
49
  def match_host(hostname)
50
50
  hostname === host
51
51
  end
52
+
53
+ # Match the submitted user agent to the given pattern, capturing any
54
+ # regexp match groups.
55
+ def match_user_agent(pattern)
56
+ if (user_agent = @env["HTTP_USER_AGENT"]) && user_agent.to_s =~ pattern
57
+ @captures.concat($~[1..-1])
58
+ end
59
+ end
52
60
  end
53
61
  end
54
62
 
@@ -30,10 +30,8 @@ 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
34
- @after ||= nil
35
- @before ||= nil
36
- end
33
+ app.opts[:before_hook] ||= nil
34
+ app.opts[:after_hook] ||= nil
37
35
  end
38
36
 
39
37
  module ClassMethods
@@ -42,17 +40,14 @@ class Roda
42
40
  # then instance_execs the given after proc, so that the given
43
41
  # after proc always executes after the previous one.
44
42
  def after(&block)
45
- if block
46
- @after = if b = @after
47
- @after = proc do |res|
48
- instance_exec(res, &b)
49
- instance_exec(res, &block)
50
- end
51
- else
52
- block
43
+ opts[:after_hook] = if b = opts[:after_hook]
44
+ proc do |res|
45
+ instance_exec(res, &b)
46
+ instance_exec(res, &block)
53
47
  end
48
+ else
49
+ block
54
50
  end
55
- @after
56
51
  end
57
52
 
58
53
  # Add a before hook. If there is already a before hook defined,
@@ -60,25 +55,14 @@ class Roda
60
55
  # then instance_execs the existing before proc, so that the given
61
56
  # before proc always executes before the previous one.
62
57
  def before(&block)
63
- if block
64
- @before = if b = @before
65
- @before = proc do
66
- instance_exec(&block)
67
- instance_exec(&b)
68
- end
69
- else
70
- block
58
+ opts[:before_hook] = if b = opts[:before_hook]
59
+ proc do
60
+ instance_exec(&block)
61
+ instance_exec(&b)
71
62
  end
63
+ else
64
+ block
72
65
  end
73
- @before
74
- end
75
-
76
- # Copy the before and after hooks into the subclasses
77
- # when inheriting
78
- def inherited(subclass)
79
- super
80
- subclass.instance_variable_set(:@before, @before)
81
- subclass.instance_variable_set(:@after, @after)
82
66
  end
83
67
  end
84
68
 
@@ -88,13 +72,13 @@ class Roda
88
72
  # Before routing, execute the before hooks, and
89
73
  # execute the after hooks before returning.
90
74
  def _route(*, &block)
91
- if b = self.class.before
75
+ if b = opts[:before_hook]
92
76
  instance_exec(&b)
93
77
  end
94
78
 
95
79
  res = super
96
80
  ensure
97
- if b = self.class.after
81
+ if b = opts[:after_hook]
98
82
  instance_exec(res, &b)
99
83
  end
100
84
  end
@@ -36,19 +36,13 @@ class Roda
36
36
  module Json
37
37
  # Set the classes to automatically convert to JSON
38
38
  def self.configure(app)
39
- app.instance_eval do
40
- @json_result_classes ||= [Array, Hash]
41
- end
39
+ app.opts[:json_result_classes] ||= [Array, Hash]
42
40
  end
43
41
 
44
42
  module ClassMethods
45
43
  # The classes that should be automatically converted to json
46
- attr_reader :json_result_classes
47
-
48
- # Copy the json_result_classes into the subclass
49
- def inherited(subclass)
50
- super
51
- subclass.instance_variable_set(:@json_result_classes, json_result_classes.dup)
44
+ def json_result_classes
45
+ opts[:json_result_classes]
52
46
  end
53
47
  end
54
48
 
@@ -63,7 +57,7 @@ class Roda
63
57
  # application/json content-type.
64
58
  def block_result_body(result)
65
59
  case result
66
- when *self.class.roda_class.json_result_classes
60
+ when *roda_class.json_result_classes
67
61
  response[CONTENT_TYPE] = APPLICATION_JSON
68
62
  convert_to_json(result)
69
63
  else
@@ -0,0 +1,233 @@
1
+ require 'stringio'
2
+ require 'mail'
3
+
4
+ class Roda
5
+ module RodaPlugins
6
+ # The mailer plugin allows your Roda application to send emails easily.
7
+ #
8
+ # class App < Roda
9
+ # plugin :render
10
+ # plugin :mailer
11
+ #
12
+ # route do |r|
13
+ # r.on "albums" do
14
+ # r.mail "added" do |album|
15
+ # @album = album
16
+ # from 'from@example.com'
17
+ # to 'to@example.com'
18
+ # cc 'cc@example.com'
19
+ # bcc 'bcc@example.com'
20
+ # subject 'Album Added'
21
+ # add_file "path/to/album_added_img.jpg"
22
+ # render(:albums_added_email) # body
23
+ # end
24
+ # end
25
+ # end
26
+ # end
27
+ #
28
+ # The default method for sending a mail is +sendmail+:
29
+ #
30
+ # App.sendmail("/albums/added", Album[1])
31
+ #
32
+ # If you want to return the <tt>Mail::Message</tt> instance for further modification,
33
+ # you can just use the +mail+ method:
34
+ #
35
+ # mail = App.mail("/albums/added", Album[1])
36
+ # mail.from 'from2@example.com'
37
+ # mail.deliver
38
+ #
39
+ # The mailer plugin uses the mail gem, so if you want to configure how
40
+ # email is sent, you can use <tt>Mail.defaults</tt> (see the mail gem documentation for
41
+ # more details):
42
+ #
43
+ # Mail.defaults do
44
+ # delivery_method :smtp, :address=>'smtp.example.com', :port=>587
45
+ # end
46
+ #
47
+ # You can support multipart emails using +text_part+ and +html_part+:
48
+ #
49
+ # r.mail "added" do |album_added|
50
+ # from 'from@example.com'
51
+ # to 'to@example.com'
52
+ # subject 'Album Added'
53
+ # text_part render('album_added.txt') # views/album_added.txt.erb
54
+ # html_part render('album_added.html') # views/album_added.html.erb
55
+ # end
56
+ #
57
+ # In addition to allowing you to use Roda's render plugin for rendering
58
+ # email bodies, you can use all of Roda's usual routing tree features
59
+ # to DRY up your code:
60
+ #
61
+ # r.on "albums/:d" do |album_id|
62
+ # @album = Album[album_id.to_i]
63
+ # from 'from@example.com'
64
+ # to 'to@example.com'
65
+ #
66
+ # r.mail "added" do
67
+ # subject 'Album Added'
68
+ # render(:albums_added_email)
69
+ # end
70
+ #
71
+ # r.mail "deleted" do
72
+ # subject 'Album Deleted'
73
+ # render(:albums_deleted_email)
74
+ # end
75
+ # end
76
+ #
77
+ # When sending a mail via +mail+ or +sendmail+, an Error will be raised
78
+ # if the mail object does not have a body. This is similar to the 404
79
+ # status that Roda uses by default for web requests that don't have
80
+ # a body. If you want to specifically send an email with an empty body, you
81
+ # can use the explicit empty string:
82
+ #
83
+ # r.mail do
84
+ # from 'from@example.com'
85
+ # to 'to@example.com'
86
+ # subject 'No Body Here'
87
+ # ""
88
+ # end
89
+ #
90
+ # By default, the mailer uses text/plain as the Content-Type for emails.
91
+ # You can override the default by specifying a :content_type option when
92
+ # loading the plugin:
93
+ #
94
+ # plugin :mailer, :content_type=>'text/html'
95
+ #
96
+ # The mailer plugin does support being used inside a Roda application
97
+ # that is handling web requests, where the routing block for mails and
98
+ # web requests is shared. However, it's recommended that you create a
99
+ # separate Roda application for emails. This can be a subclass of your main
100
+ # Roda application if you want your helper methods to automatically be
101
+ # available in your email views.
102
+ module Mailer
103
+ REQUEST_METHOD = "REQUEST_METHOD".freeze
104
+ PATH_INFO = "PATH_INFO".freeze
105
+ SCRIPT_NAME = 'SCRIPT_NAME'.freeze
106
+ EMPTY_STRING = ''.freeze
107
+ RACK_INPUT = 'rack.input'.freeze
108
+ RODA_MAIL = 'roda.mail'.freeze
109
+ RODA_MAIL_ARGS = 'roda.mail_args'.freeze
110
+ MAIL = "MAIL".freeze
111
+ CONTENT_TYPE = 'Content-Type'.freeze
112
+ TEXT_PLAIN = "text/plain".freeze
113
+
114
+ # Error raised when the using the mail class method, but the routing
115
+ # tree doesn't return the mail object.
116
+ class Error < ::Roda::RodaError; end
117
+
118
+ # Set the options for the mailer. Options:
119
+ # :content_type :: The default content type for emails (default: text/plain)
120
+ def self.configure(app, opts={})
121
+ app.opts[:mailer] = (app.opts[:mailer]||{}).merge(opts).freeze
122
+ end
123
+
124
+ module ClassMethods
125
+ # Return a Mail::Message instance for the email for the given request path
126
+ # and arguments. You can further manipulate the returned mail object before
127
+ # calling +deliver+ to send the mail.
128
+ def mail(path, *args)
129
+ mail = ::Mail.new
130
+ unless mail.equal?(allocate.call(PATH_INFO=>path, SCRIPT_NAME=>EMPTY_STRING, REQUEST_METHOD=>MAIL, RACK_INPUT=>StringIO.new, RODA_MAIL=>mail, RODA_MAIL_ARGS=>args, &route_block))
131
+ raise Error, "route did not return mail instance for #{path.inspect}, #{args.inspect}"
132
+ end
133
+ mail
134
+ end
135
+
136
+ # Calls +mail+ and immediately sends the resulting mail.
137
+ def sendmail(*args)
138
+ mail(*args).deliver
139
+ end
140
+ end
141
+
142
+ module RequestMethods
143
+ # Similar to routing tree methods such as +get+ and +post+, this matches
144
+ # only if the request method is MAIL (only set when using the Roda class
145
+ # +mail+ or +sendmail+ methods) and the rest of the arguments match
146
+ # the request. This yields any of the captures to the block, as well as
147
+ # any arguments passed to the +mail+ or +sendmail+ Roda class methods.
148
+ def mail(*args)
149
+ if @env[REQUEST_METHOD] == MAIL
150
+ if_match(args) do |*vs|
151
+ yield *(vs + @env[RODA_MAIL_ARGS])
152
+ end
153
+ end
154
+ end
155
+ end
156
+
157
+ module ResponseMethods
158
+ # The mail object related to the current request.
159
+ attr_accessor :mail
160
+
161
+ # If the related request was an email request, add any response headers
162
+ # to the email, as well as adding the response body to the email.
163
+ # Return the email unless no body was set for it, which would indicate
164
+ # that the routing tree did not handle the request.
165
+ def finish
166
+ if m = mail
167
+ header_content_type = @headers.delete(CONTENT_TYPE)
168
+ m.headers(@headers)
169
+ m.body(@body.join) unless @body.empty?
170
+
171
+ if content_type = header_content_type || roda_class.opts[:mailer][:content_type]
172
+ if mail.multipart?
173
+ if mail.content_type =~ /multipart\/mixed/ &&
174
+ mail.parts.length >= 2 &&
175
+ (part = mail.parts.find{|p| !p.attachment && p.content_type == TEXT_PLAIN})
176
+ part.content_type = content_type
177
+ end
178
+ else
179
+ mail.content_type = content_type
180
+ end
181
+ end
182
+
183
+ unless m.body.to_s.empty? && m.parts.empty? && @body.empty?
184
+ m
185
+ end
186
+ else
187
+ super
188
+ end
189
+ end
190
+ end
191
+
192
+ module InstanceMethods
193
+ # Add delegates for common email methods.
194
+ [:from, :to, :cc, :bcc, :subject, :add_file].each do |meth|
195
+ define_method(meth) do |*args|
196
+ env[RODA_MAIL].send(meth, *args)
197
+ nil
198
+ end
199
+ end
200
+ [:text_part, :html_part].each do |meth|
201
+ define_method(meth) do |*args|
202
+ _mail_part(meth, *args)
203
+ end
204
+ end
205
+
206
+ private
207
+
208
+ # If this is an email request, set the mail object in the response, as well
209
+ # as the default content_type for the email.
210
+ def _route
211
+ if mail = env[RODA_MAIL]
212
+ res = @_response
213
+ res.mail = mail
214
+ res.headers.delete(CONTENT_TYPE)
215
+ end
216
+ super
217
+ end
218
+
219
+ # Set the text_part or html_part (depending on the method) in the related email,
220
+ # using the given body and optional headers.
221
+ def _mail_part(meth, body, headers=nil)
222
+ env[RODA_MAIL].send(meth) do
223
+ body(body)
224
+ headers(headers) if headers
225
+ end
226
+ nil
227
+ end
228
+ end
229
+ end
230
+
231
+ register_plugin(:mailer, Mailer)
232
+ end
233
+ end