roda 1.1.0 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
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