diff --git a/CHANGELOG b/CHANGELOG
index f2788f96..1dfa2220 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,5 +1,7 @@
= master
+* Make class_matcher and symbol_matcher plugin be able to build on top of existing registered matchers (jeremyevans)
+
* Make capture_erb plugin not break if String#capture is defined (jeremyevans)
= 3.84.0 (2024-09-12)
diff --git a/lib/roda/plugins/Integer_matcher_max.rb b/lib/roda/plugins/Integer_matcher_max.rb
index 6ca17489..1a2f8124 100644
--- a/lib/roda/plugins/Integer_matcher_max.rb
+++ b/lib/roda/plugins/Integer_matcher_max.rb
@@ -29,6 +29,16 @@ def self.configure(app, max=nil)
private :_match_class_max_Integer
end
end
+ app.opts[:Integer_matcher_max] = max || 9223372036854775807
+ end
+
+ module ClassMethods
+ # Integrate with class matchers created by using class_matcher
+ # with the Integer class as the matcher.
+ def match_class_convert_Integer(i)
+ value = i.to_i
+ value if value <= opts[:Integer_matcher_max]
+ end
end
module RequestMethods
diff --git a/lib/roda/plugins/class_matchers.rb b/lib/roda/plugins/class_matchers.rb
index 28eda200..7cc78b34 100644
--- a/lib/roda/plugins/class_matchers.rb
+++ b/lib/roda/plugins/class_matchers.rb
@@ -40,19 +40,124 @@ module RodaPlugins
# [Date.new(y, m, d)] if Date.valid_date?(y, m, d)
# end
#
+ # The second argument to class_matcher can be a class already registered
+ # as a class matcher. This can DRY up code that wants a conversion
+ # performed by an existing class matcher:
+ #
+ # class_matcher Employee, Integer do |id|
+ # Employee[id]
+ # end
+ #
+ # With the above example, the Integer matcher performs the conversion to
+ # integer, so +id+ is yielded as an integer. The block then looks up the
+ # employee with that id. If there is no employee with that id, then
+ # the Employee matcher will not match.
+ #
+ # If using the symbol_matchers plugin, you can provide a recognized symbol
+ # matcher as the second argument to class_matcher, and it will work in
+ # a similar manner:
+ #
+ # symbol_matcher(:employee_id, /E-(\d{6})/) do |employee_id|
+ # employee_id.to_i
+ # end
+ # class_matcher Employee, :employee_id do |id|
+ # Employee[id]
+ # end
+ #
# This plugin does not work with the params_capturing plugin, as it does not
# offer the ability to associate block arguments with named keys.
module ClassMatchers
+ def self.configure(app)
+ app.opts[:class_matchers] ||= {
+ Integer=>[/(\d{1,100})/, /\A\/(\d{1,100})(?=\/|\z)/, proc{|i| if i = app.match_class_convert_Integer(i); [i] end}].freeze,
+ String=>[/([^\/]+)/, /\A\/([^\/]+)(?=\/|\z)/, nil].freeze
+ }
+ end
+
module ClassMethods
- # Set the regexp to use for the given class. The block given will be
- # called with all matched values from the regexp, and should return an
- # array with the captures to yield to the match block.
- def class_matcher(klass, re, &block)
+ # Set the matcher and block to use for the given class.
+ # The matcher can be a regexp, registered class matcher, or registered symbol
+ # matcher (if using the symbol_matchers plugin).
+ #
+ # If providing a regexp, the block given will be called with all regexp captures.
+ # If providing a registered class or symbol, the block will be called with the
+ # captures returned by the block for the registered class or symbol, or the regexp
+ # captures if no block was registered with the class or symbol. In either case,
+ # if a block is given, it should return an array with the captures to yield to
+ # the match block.
+ def class_matcher(klass, matcher, &block)
meth = :"_match_class_#{klass}"
+ opts = self.opts
self::RodaRequest.class_eval do
- consume_re = consume_pattern(re)
- define_method(meth){consume(consume_re, &block)}
+ case matcher
+ when Regexp
+ regexp_matcher = matcher
+ regexp = consume_pattern(matcher)
+ define_method(meth){consume(regexp, &block)}
+ when Class
+ regexp_matcher, regexp, matcher_block = opts[:class_matchers][matcher]
+ unless regexp
+ raise RodaError, "unregistered class matcher given to class_matcher: #{matcher.inspect}"
+ end
+
+ block = merge_class_matcher_blocks(block, matcher_block)
+ define_method(meth){consume(regexp, &block)}
+ when Symbol
+ unless opts[:symbol_matchers]
+ raise RodaError, "cannot provide Symbol matcher to class_matcher unless using symbol_matchers plugin: #{matcher.inspect}"
+ end
+
+ regexp_matcher, regexp, matcher_block = opts[:symbol_matchers][matcher]
+ unless regexp
+ raise RodaError, "unregistered symbol matcher given to class_matcher: #{matcher.inspect}"
+ end
+
+ block = merge_class_matcher_blocks(block, matcher_block)
+ define_method(meth){consume(regexp, &block)}
+ else
+ raise RodaError, "unsupported matcher given to class_matcher: #{matcher.inspect}"
+ end
+
private meth
+ opts[:class_matchers][klass] = [regexp_matcher, regexp, block].freeze
+ nil
+ end
+ end
+
+ # Integrate with the Integer_matcher_max plugin.
+ def match_class_convert_Integer(i)
+ return super if defined?(super)
+ i.to_i
+ end
+
+ # Freeze the class_matchers hash when freezing the app.
+ def freeze
+ opts[:class_matchers].freeze
+ super
+ end
+ end
+
+ module RequestClassMethods
+ private
+
+ # If both block and matcher_block are given, return a
+ # proc that calls matcher block first, and only calls
+ # block with the return values of matcher_block if
+ # the matcher_block returns an array.
+ # Otherwise, return matcher_block or block.
+ def merge_class_matcher_blocks(block, matcher_block)
+ if matcher_block
+ if block
+ proc do |*a|
+ if captures = matcher_block.call(*a)
+ block.call(*captures)
+ end
+ end
+ else
+ matcher_block
+ end
+ elsif block
+ block
end
end
end
diff --git a/lib/roda/plugins/placeholder_string_matchers.rb b/lib/roda/plugins/placeholder_string_matchers.rb
index 50e5c0ff..e39661e4 100644
--- a/lib/roda/plugins/placeholder_string_matchers.rb
+++ b/lib/roda/plugins/placeholder_string_matchers.rb
@@ -21,6 +21,10 @@ module RodaPlugins
#
# r.is "foo", String
# r.is "foo", :bar
+ #
+ # If used with the symbol_matchers plugin, this plugin respects the regexps
+ # for the registered symbols, but it does not perform the conversions, the
+ # captures for the regexp are used directly as the captures for the match method.
module PlaceholderStringMatchers
def self.load_dependencies(app)
app.plugin :_symbol_regexp_matchers
diff --git a/lib/roda/plugins/symbol_matchers.rb b/lib/roda/plugins/symbol_matchers.rb
index cb80814a..22a0b0d1 100644
--- a/lib/roda/plugins/symbol_matchers.rb
+++ b/lib/roda/plugins/symbol_matchers.rb
@@ -23,7 +23,7 @@ module RodaPlugins
#
# :d :: /(\d+)/, a decimal segment
# :rest :: /(.*)/, all remaining characters, if any
- # :w :: /(\w+)/, a alphanumeric segment
+ # :w :: /(\w+)/, an alphanumeric segment
#
# If the placeholder_string_matchers plugin is loaded, this feature also applies to
# placeholders in strings, so the following:
@@ -64,7 +64,32 @@ module RodaPlugins
# [Date.new(y, m, d)] if Date.valid_date?(y, m, d)
# end
#
- # However, if providing a block to the symbol_matchers plugin, the symbol may
+ # The second argument to symbol_matcher can be a symbol already registered
+ # as a symbol matcher. This can DRY up code that wants a conversion
+ # performed by an existing class matcher or to use the same regexp:
+ #
+ # symbol_matcher :employee_id, :d do |id|
+ # id.to_i
+ # end
+ # symbol_matcher :employee, :employee_id do |id|
+ # Employee[id]
+ # end
+ #
+ # With the above example, the :d matcher matches only decimal strings, but
+ # yields them as string. The registered :employee_id matcher converts the
+ # decimal string to an integer. The registered :employee matcher builds
+ # on that and uses the integer to lookup the related employee. If there is
+ # no employee with that id, then the :employee matcher will not match.
+ #
+ # If using the class_matchers plugin, you can provide a recognized class
+ # matcher as the second argument to symbol_matcher, and it will work in
+ # a similar manner:
+ #
+ # symbol_matcher :employee, Integer do |id|
+ # Employee[id]
+ # end
+ #
+ # If providing a block to the symbol_matchers plugin, the symbol may
# not work with the params_capturing plugin.
module SymbolMatchers
def self.load_dependencies(app)
@@ -72,19 +97,85 @@ def self.load_dependencies(app)
end
def self.configure(app)
+ app.opts[:symbol_matchers] ||= {}
app.symbol_matcher(:d, /(\d+)/)
app.symbol_matcher(:w, /(\w+)/)
app.symbol_matcher(:rest, /(.*)/)
end
module ClassMethods
- # Set the regexp to use for the given symbol, instead of the default.
- def symbol_matcher(s, re, &block)
+ # Set the matcher and block to use for the given class.
+ # The matcher can be a regexp, registered symbol matcher, or registered class
+ # matcher (if using the class_matchers plugin).
+ #
+ # If providing a regexp, the block given will be called with all regexp captures.
+ # If providing a registered symbol or class, the block will be called with the
+ # captures returned by the block for the registered symbol or class, or the regexp
+ # captures if no block was registered with the symbol or class. In either case,
+ # if a block is given, it should return an array with the captures to yield to
+ # the match block.
+ def symbol_matcher(s, matcher, &block)
meth = :"match_symbol_#{s}"
- array = [re, block].freeze
+
+ case matcher
+ when Regexp
+ regexp = matcher
+ consume_regexp = self::RodaRequest.send(:consume_pattern, regexp)
+ when Symbol
+ regexp, consume_regexp, matcher_block = opts[:symbol_matchers][matcher]
+
+ unless regexp
+ raise RodaError, "unregistered symbol matcher given to symbol_matcher: #{matcher.inspect}"
+ end
+
+ block = merge_symbol_matcher_blocks(block, matcher_block)
+ when Class
+ unless opts[:class_matchers]
+ raise RodaError, "cannot provide Class matcher to symbol_matcher unless using class_matchers plugin: #{matcher.inspect}"
+ end
+
+ regexp, consume_regexp, matcher_block = opts[:class_matchers][matcher]
+ unless regexp
+ raise RodaError, "unregistered class matcher given to symbol_matcher: #{matcher.inspect}"
+ end
+ block = merge_symbol_matcher_blocks(block, matcher_block)
+ else
+ raise RodaError, "unsupported matcher given to symbol_matcher: #{matcher.inspect}"
+ end
+
+ array = opts[:symbol_matchers][s] = [regexp, consume_regexp, block].freeze
self::RodaRequest.send(:define_method, meth){array}
self::RodaRequest.send(:private, meth)
end
+
+ # Freeze the class_matchers hash when freezing the app.
+ def freeze
+ opts[:symbol_matchers].freeze
+ super
+ end
+
+ private
+
+ # If both block and matcher_block are given, return a
+ # proc that calls matcher block first, and only calls
+ # block with the return values of matcher_block if
+ # the matcher_block returns an array.
+ # Otherwise, return matcher_block or block.
+ def merge_symbol_matcher_blocks(block, matcher_block)
+ if matcher_block
+ if block
+ proc do |*a|
+ if captures = matcher_block.call(*a)
+ block.call(*captures)
+ end
+ end
+ else
+ matcher_block
+ end
+ elsif block
+ block
+ end
+ end
end
module RequestMethods
@@ -97,8 +188,8 @@ def _match_symbol(s)
meth = :"match_symbol_#{s}"
if respond_to?(meth, true)
# Allow calling private match methods
- re, block = send(meth)
- consume(self.class.cached_matcher(re){re}, &block)
+ _, re, block = send(meth)
+ consume(re, &block)
else
super
end
diff --git a/spec/plugin/class_matchers_spec.rb b/spec/plugin/class_matchers_spec.rb
index 90210c73..2eea7664 100644
--- a/spec/plugin/class_matchers_spec.rb
+++ b/spec/plugin/class_matchers_spec.rb
@@ -9,8 +9,59 @@
class_matcher(Array, /(\w+)\/(\w+)/){|a, b| [[a, 1], [b, 2]]}
class_matcher(Hash, /(\d+)\/(\d+)/){|a, b| [{a.to_i=>b.to_i}]}
+ klass = Class.new
+ def klass.to_s; "klass" end
+ class_matcher(klass, Integer){|i| [i*2] unless i == 10}
+
+ plugin :symbol_matchers
+ symbol_matcher(:i, /i(\d+)/, &:to_i)
+ klass2 = Class.new
+ def klass2.to_s; "klass2" end
+ class_matcher(klass2, :i){|i| [i*3]}
+
+ symbol_matcher(:j, /j(\d+)/)
+ klass3 = Class.new
+ def klass3.to_s; "klass3" end
+ class_matcher(klass3, :j){|j| [j*3]}
+
+ klass4 = Class.new
+ def klass4.to_s; "klass4" end
+ class_matcher(klass4, String){|i| [i*2]}
+
+ klass5 = Class.new
+ def klass5.to_s; "klass5" end
+ class_matcher(klass5, klass){|i| [i*3]}
+
+ klass6 = Class.new
+ def klass6.to_s; "klass6" end
+ class_matcher(klass6, klass)
+
+ klass7 = Class.new
+ def klass7.to_s; "klass7" end
+ class_matcher(klass7, String)
+
+ klass8 = Class.new
+ def klass8.to_s; "klass8" end
+ class_matcher(klass8, :d){|i| [i*2]}
+
+ klass9 = Class.new
+ def klass9.to_s; "klass9" end
+ class_matcher(klass9, :d)
+
route do |r|
r.on Array do |(a,b), (c,d)|
+ r.get 'X', klass5 do |i|
+ [a, b, c, d, i].join('-')
+ end
+ r.get 'Y', [klass6, klass7] do |i|
+ [a, b, c, d, i].join('-')
+ end
+ r.get 'Z1', klass8 do |i|
+ [a, b, c, d, i].join('-')
+ end
+ r.get 'Z2', klass9 do |i|
+ [a, b, c, d, i].join('-')
+ end
r.get Date do |date|
[date.year, date.month, date.day, a, b, c, d].join('-')
end
@@ -20,6 +71,18 @@
r.get Array do |(a1,b1), (c1,d1)|
[a1, b1, c1, d1, a, b, c, d].join('-')
end
+ r.get klass do |i|
+ [a, b, c, d, i].join('-') + '-1'
+ end
+ r.get klass2 do |i|
+ [a, b, c, d, i].join('-') + '-2'
+ end
+ r.get klass3 do |i|
+ [a, b, c, d, i].join('-') + '-3'
+ end
+ r.get klass4 do |i|
+ [a, b, c, d, i].join('-') + '-4'
+ end
r.is do
[a, b, c, d].join('-')
end
@@ -31,11 +94,66 @@
body("/c").must_equal ''
body("/c/d").must_equal 'c-1-d-2'
- body("/c/d/e").must_equal 'array'
- body("/c/d/2009-10-a").must_equal 'array'
+ body("/c/d/e/f/g").must_equal 'array'
+ body("/c/d/2009-10-a").must_equal 'c-1-d-2-2009-10-a2009-10-a-4'
body("/c/d/2009-10-01").must_equal '2009-10-1-c-1-d-2'
- body("/c/d/2009-13-01").must_equal 'array'
+ body("/c/d/2009-13-01").must_equal "c-1-d-2-2009-13-012009-13-01-4"
body("/c/d/1/2").must_equal '{1=>2}-c-1-d-2'
body("/c/d/e/f").must_equal 'e-1-f-2-c-1-d-2'
+ body("/c/d/3").must_equal 'c-1-d-2-6-1'
+ body("/c/d/10").must_equal 'c-1-d-2-1010-4'
+ body("/c/d/i3").must_equal 'c-1-d-2-9-2'
+ body("/c/d/j3").must_equal 'c-1-d-2-333-3'
+ body("/c/d/i").must_equal 'c-1-d-2-ii-4'
+ body("/c/d/X/3").must_equal 'c-1-d-2-18'
+ body("/c/d/X/10").must_equal 'X-1-10-2-c-1-d-2'
+ body("/c/d/Y/3").must_equal 'c-1-d-2-6'
+ body("/c/d/Y/a").must_equal 'c-1-d-2-a'
+ body("/c/d/Z1/3").must_equal 'c-1-d-2-33'
+ body("/c/d/Z2/3").must_equal 'c-1-d-2-3'
+ end
+
+ it "raises errors for unsupported calls to class matcher" do
+ app(:class_matchers){}
+ c = Class.new
+ proc{app.class_matcher(c, Hash)}.must_raise Roda::RodaError
+ proc{app.class_matcher(c, :foo)}.must_raise Roda::RodaError
+ app.plugin :symbol_matchers
+ proc{app.class_matcher(c, :foo)}.must_raise Roda::RodaError
+ proc{app.class_matcher(c, Object.new)}.must_raise Roda::RodaError
+ end
+
+ it "respects Integer_matcher_max plugin when using class_matcher with Integer matcher" do
+ c = Class.new
+ app(:class_matchers){|r| r.is(c){|x| (x*3).to_s}}
+ app.class_matcher(c, Integer)
+ body("/4").must_equal "12"
+ body("/1000000000000000000000").must_equal "3000000000000000000000"
+ app.plugin :Integer_matcher_max
+ body("/1000000000000000000000").must_equal ""
+ app.plugin :Integer_matcher_max, 1000000000000000000000
+ body("/1000000000000000000000").must_equal "3000000000000000000000"
+ body("/1000000000000000000001").must_equal ""
+ end
+
+ it "respects Integer_matcher_max plugin when loaded first" do
+ c = Class.new
+ app(:bare) do
+ plugin :Integer_matcher_max
+ plugin :class_matchers
+ route{|r| r.is(c){|x| (x*3).to_s}}
+ end
+ app.class_matcher(c, Integer)
+ body("/4").must_equal "12"
+ body("/1000000000000000000000").must_equal ""
+ app.plugin :Integer_matcher_max, 1000000000000000000000
+ body("/1000000000000000000000").must_equal "3000000000000000000000"
+ body("/1000000000000000000001").must_equal ""
+ end
+
+ it "freezes :class_matchers option when freezing app" do
+ app(:class_matchers){|r| }
+ app.freeze
+ app.opts[:class_matchers].frozen?.must_equal true
end
end
diff --git a/spec/plugin/placeholder_string_matchers_spec.rb b/spec/plugin/placeholder_string_matchers_spec.rb
index 67194d7a..8a6f7dca 100644
--- a/spec/plugin/placeholder_string_matchers_spec.rb
+++ b/spec/plugin/placeholder_string_matchers_spec.rb
@@ -81,7 +81,24 @@
plugin :symbol_matchers
symbol_matcher(:f, /(f+)/)
+ plugin :class_matchers
+ symbol_matcher(:s, String)
+ symbol_matcher(:i, Integer)
+ symbol_matcher(:j, :i)
+
route do |r|
+ r.on "X" do
+ r.is "j/:j" do |i|
+ "j-#{i}"
+ end
+ r.is "i/:i" do |i|
+ "i-#{i}"
+ end
+ r.is "s/:s" do |s|
+ "s-#{s}"
+ end
+ end
+
r.is ":d" do |d|
"d#{d}"
end
@@ -127,5 +144,9 @@
body('/q').must_equal 'rest'
body('/thing/q').must_equal 'thingq'
body('/thing2/q').must_equal 'thing2q'
+
+ body("/X/j/1").must_equal 'j-1'
+ body("/X/i/3").must_equal 'i-3'
+ body("/X/s/a").must_equal 's-a'
end
end
diff --git a/spec/plugin/symbol_matchers_spec.rb b/spec/plugin/symbol_matchers_spec.rb
index 412d7caa..711baa01 100644
--- a/spec/plugin/symbol_matchers_spec.rb
+++ b/spec/plugin/symbol_matchers_spec.rb
@@ -4,12 +4,69 @@
it "allows symbol specific regexps for symbol matchers" do
app(:bare) do
plugin :symbol_matchers
+ symbol_matcher(:d2, :d)
+
symbol_matcher(:f, /(f+)/)
+ symbol_matcher(:f2, :f) do |fs|
+ [fs*2]
+ end
+ symbol_matcher(:f3, :f)
+
symbol_matcher(:c, /(c+)/) do |cs|
[cs, cs.length] unless cs.length == 5
end
+ symbol_matcher(:c2, :c) do |cs, len|
+ [len]
+ end
+ symbol_matcher(:c3, :c)
+
+ plugin :class_matchers
+ symbol_matcher(:int, Integer) do |i|
+ [i*2]
+ end
+ symbol_matcher(:i, Integer)
+ symbol_matcher(:str, String) do |s|
+ [s*2]
+ end
+ symbol_matcher(:s, String)
route do |r|
+ r.on "X" do
+ r.is "d2", :d2 do |x|
+ "d2-#{x}"
+ end
+ r.is "f", :f do |x|
+ "f-#{x}"
+ end
+ r.is "f2", :f2 do |x|
+ "f2-#{x}"
+ end
+ r.is "f3", :f3 do |x|
+ "f3-#{x}"
+ end
+ r.is "c", :c do |x, len|
+ "c-#{x}-#{len}"
+ end
+ r.is "c2", :c2 do |x|
+ "c2-#{x}"
+ end
+ r.is "c3", :c3 do |x, len|
+ "c3-#{x}-#{len}"
+ end
+ r.is "int", :int do |x|
+ "int-#{x}"
+ end
+ r.is "i", :i do |x|
+ "i-#{x}"
+ end
+ r.is "str", :str do |x|
+ "str-#{x}"
+ end
+ r.is "s", :s do |x|
+ "s-#{x}"
+ end
+ end
+
r.is :d do |d|
"d#{d}"
end
@@ -26,6 +83,14 @@
"#{cs}#{nc}"
end
+ r.is 'x', :c2 do |len|
+ len.inspect
+ end
+
+ r.is 'y', :int do |i|
+ i.inspect
+ end
+
r.is 'q', :rest do |rest|
"rest#{rest}"
end
@@ -50,6 +115,9 @@
body("/c").must_equal 'c1'
body("/cccc").must_equal 'cccc4'
body("/ccccc").must_equal 'wccccc'
+ body("/x/c").must_equal '1'
+ body("/x/cccc").must_equal '4'
+ body("/y/3").must_equal '6'
status("/-").must_equal 404
body("/1/1a/f/cc").must_equal 'dwfc11afcc2'
body("/12/1azy/fffff/ccc").must_equal 'dwfc121azyfffffccc3'
@@ -57,5 +125,35 @@
body("/q/a/b/c/d//f/g").must_equal 'resta/b/c/d//f/g'
body('/q/').must_equal 'rest'
body('/thing2/q').must_equal 'thing2q'
+
+ body('/X/d2/1').must_equal 'd2-1'
+ body('/X/f/fff').must_equal 'f-fff'
+ body('/X/f2/ff').must_equal 'f2-ffff'
+ body('/X/f3/fff').must_equal 'f3-fff'
+ body('/X/c/ccc').must_equal 'c-ccc-3'
+ body('/X/c/ccccc').must_equal ''
+ body('/X/c2/ccc').must_equal 'c2-3'
+ body('/X/c2/ccccc').must_equal ''
+ body('/X/c3/ccc').must_equal 'c3-ccc-3'
+ body('/X/c3/ccccc').must_equal ''
+ body('/X/int/3').must_equal 'int-6'
+ body('/X/i/3').must_equal 'i-3'
+ body('/X/str/f').must_equal 'str-ff'
+ body('/X/s/f').must_equal 's-f'
+ end
+
+ it "raises errors for unsupported calls to class matcher" do
+ app(:symbol_matchers){|r| }
+ proc{app.symbol_matcher(:sym, :foo)}.must_raise Roda::RodaError
+ proc{app.symbol_matcher(:sym, Integer)}.must_raise Roda::RodaError
+ app.plugin :class_matchers
+ proc{app.symbol_matcher(:sym, Hash)}.must_raise Roda::RodaError
+ proc{app.symbol_matcher(:sym, Object.new)}.must_raise Roda::RodaError
+ end
+
+ it "freezes :symbol_matchers option when freezing app" do
+ app(:symbol_matchers){|r| }
+ app.freeze
+ app.opts[:symbol_matchers].frozen?.must_equal true
end
end