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