Skip to content

Commit

Permalink
Allow route globbing (#40)
Browse files Browse the repository at this point in the history
  • Loading branch information
matthewmcgarvey authored Oct 27, 2020
1 parent 6af06f6 commit e21fca1
Show file tree
Hide file tree
Showing 6 changed files with 183 additions and 16 deletions.
45 changes: 45 additions & 0 deletions spec/integration_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,51 @@ describe LuckyRouter do
show_match.params.should eq({"id" => "123"})
end

it "requires globs to be on the end of the path" do
router = LuckyRouter::Matcher(Symbol).new
expect_raises LuckyRouter::InvalidPathError do
router.add("get", "/posts/*/invalid_path", :invalid_path)
end
end

it "allows route globbing" do
router = LuckyRouter::Matcher(Symbol).new
router.add("get", "/posts/something/*", :post_index)

router.match!("get", "/posts/something").params.should eq({} of String => String)

router.match!("get", "/posts/something/1").params.should eq({
"glob" => "1",
})

router.match!("get", "/posts/something/1/something/longer").params.should eq({
"glob" => "1/something/longer",
})
end

it "allows route globbing and optional parts" do
router = LuckyRouter::Matcher(Symbol).new
router.add("get", "/posts/something/?:optional_1/?:optional_2/*:glob_param", :post_index)

router.match!("get", "/posts/something/1").params.should eq({
"optional_1" => "1",
})
router.match!("get", "/posts/something/1/2").params.should eq({
"optional_1" => "1",
"optional_2" => "2",
})
router.match!("get", "/posts/something/1/2/3").params.should eq({
"optional_1" => "1",
"optional_2" => "2",
"glob_param" => "3",
})
router.match!("get", "/posts/something/1/2/3/4").params.should eq({
"optional_1" => "1",
"optional_2" => "2",
"glob_param" => "3/4",
})
end

describe "route with trailing slash" do
router = LuckyRouter::Matcher(Symbol).new
router.add("get", "/users/:id", :show)
Expand Down
56 changes: 55 additions & 1 deletion spec/path_part_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@ describe LuckyRouter::PathPart do

path_part.path_variable?.should be_truthy
end

it "is true if it is a glob path variable" do
path_part = LuckyRouter::PathPart.new("*:id")

path_part.path_variable?.should be_truthy
end

it "is true if it is just a glob so that it will be assigned correctly" do
path_part = LuckyRouter::PathPart.new("*")

path_part.path_variable?.should be_truthy
end
end

describe "#optional?" do
Expand All @@ -54,6 +66,20 @@ describe LuckyRouter::PathPart do
end
end

describe "#glob?" do
it "is true if starts with asterisk" do
path_part = LuckyRouter::PathPart.new("*")

path_part.glob?.should be_truthy
end

it "is false if does not start with asterisk" do
path_part = LuckyRouter::PathPart.new("users")

path_part.glob?.should be_falsey
end
end

describe "#name" do
it "returns part if part is not path variable" do
path_part = LuckyRouter::PathPart.new("users")
Expand All @@ -78,6 +104,18 @@ describe LuckyRouter::PathPart do

path_part.name.should eq "id"
end

it "handles glob path variables" do
path_part = LuckyRouter::PathPart.new("*:id")

path_part.name.should eq "id"
end

it "is glob if glob without path variable name" do
path_part = LuckyRouter::PathPart.new("*")

path_part.name.should eq "glob"
end
end

describe "equality" do
Expand All @@ -89,12 +127,28 @@ describe LuckyRouter::PathPart do
part_a.hash.should eq part_b.hash
end

it "is not equal to another path par if their part is different" do
it "is not equal to another path part if their part is different" do
part_a = LuckyRouter::PathPart.new("users")
part_b = LuckyRouter::PathPart.new(":users")

part_a.should_not eq part_b
part_a.hash.should_not eq part_b.hash
end
end

describe "#validate!" do
it "does nothing if path part is valid" do
part = LuckyRouter::PathPart.new("users")

part.validate!
end

it "raises error if glob named incorrectly" do
part = LuckyRouter::PathPart.new("*users")

expect_raises(LuckyRouter::InvalidGlobError) do
part.validate!
end
end
end
end
13 changes: 13 additions & 0 deletions src/lucky_router/errors.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module LuckyRouter
abstract class LuckyRouterError < Exception
end

class InvalidPathError < LuckyRouterError
end

class InvalidGlobError < LuckyRouterError
def initialize(glob)
super "Tried to define a glob as `#{glob}`, but it is invalid. Globs must be defined like `*` or given a name like `*:name`."
end
end
end
43 changes: 30 additions & 13 deletions src/lucky_router/fragment.cr
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
class LuckyRouter::Fragment(T)
getter dynamic_parts = Array(Fragment(T)).new
getter static_parts = Hash(String, Fragment(T)).new
property glob_part : Fragment(T)?
# Every path can have multiple request methods
# and since each fragment represents a request path
# the final step to finding the payload is to search for a matching request method
Expand All @@ -61,7 +62,9 @@ class LuckyRouter::Fragment(T)
end

def add_part(path_part : PathPart) : Fragment(T)
if path_part.path_variable?
if path_part.glob?
self.glob_part = Fragment(T).new(path_part: path_part)
elsif path_part.path_variable?
existing = self.dynamic_parts.find { |fragment| fragment.path_part == path_part }
return existing if existing

Expand All @@ -78,30 +81,44 @@ class LuckyRouter::Fragment(T)
end

def find_match(path_parts : Array(String), method : String) : Match(T)?
if path_parts.empty?
payload = method_to_payload[method]?
return payload ? Match(T).new(payload, Hash(String, String).new) : nil
end
return match_for_method(method) if path_parts.empty?

path_part = path_parts.first
rest = path_parts[1..]

find_match_with_static_parts(path_part, rest, method) || find_match_with_dynamics(path_part, rest, method)
find_match_with_static_parts(path_part, rest, method) ||
find_match_with_dynamics(path_part, rest, method) ||
find_match_with_glob(path_part, rest, method)
end

def match_for_method(method)
payload = method_to_payload[method]?
payload ? Match(T).new(payload, Hash(String, String).new) : nil
end

private def find_match_with_static_parts(path_part, rest, method)
match = static_parts[path_part]?
return unless match
static_part = static_parts[path_part]?
return unless static_part

match.find_match(rest, method)
static_part.find_match(rest, method)
end

private def find_match_with_dynamics(path_part, rest, method)
dynamic_parts.each do |part|
if result = part.find_match(rest, method)
result.params[part.path_part.name] = path_part
return result
dynamic_parts.each do |dynamic_part|
if match = dynamic_part.find_match(rest, method)
match.params[dynamic_part.path_part.name] = path_part
return match
end
end
end

private def find_match_with_glob(path_part, rest, method)
glob = glob_part
return unless glob

if match = glob.match_for_method(method)
match.params[glob.path_part.name] = rest.unshift(path_part).join("/")
match
end
end
end
19 changes: 19 additions & 0 deletions src/lucky_router/matcher.cr
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ class LuckyRouter::Matcher(T)

def add(method : String, path : String, payload : T)
all_path_parts = PathPart.split_path(path)
validate!(path, all_path_parts)
optional_parts = all_path_parts.select(&.optional?)
glob_part = nil
if last_part = all_path_parts.last?
glob_part = all_path_parts.pop if last_part.glob?
end

path_without_optional_params = all_path_parts.reject(&.optional?)

Expand All @@ -30,6 +35,10 @@ class LuckyRouter::Matcher(T)
path_without_optional_params << optional_part
process_and_add_path(method, path_without_optional_params, payload)
end
if glob_part
path_without_optional_params << glob_part
process_and_add_path(method, path_without_optional_params, payload)
end
end

private def process_and_add_path(method : String, parts : Array(PathPart), payload : T)
Expand All @@ -53,4 +62,14 @@ class LuckyRouter::Matcher(T)
def match!(method : String, path_to_match : String) : Match(T)
match(method, path_to_match) || raise "No matching route found for: #{path_to_match}"
end

private def validate!(path : String, parts : Array(PathPart))
last_index = parts.size - 1
parts.each_with_index do |part, idx|
if part.glob? && idx != last_index
raise InvalidPathError.new("`#{path}` must only contain a glob at the end")
end
part.validate!
end
end
end
23 changes: 21 additions & 2 deletions src/lucky_router/path_part.cr
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,33 @@ struct LuckyRouter::PathPart
end

def name
part.lchop('?').lchop(':')
name = part.lchop('?').lchop('*').lchop(':')
unnamed_glob?(name) ? "glob" : name
end

def optional?
part.starts_with?('?')
end

def path_variable?
part.starts_with?(':') || part.starts_with?("?:")
part.starts_with?(':') || part.starts_with?("?:") || glob?
end

def glob?
part.starts_with?('*')
end

def validate!
raise InvalidGlobError.new(part) if invalid_glob?
end

private def unnamed_glob?(name)
name.blank? && glob?
end

private def invalid_glob?
return false unless glob?

part.size != 1 && part != "*:#{name}"
end
end

0 comments on commit e21fca1

Please sign in to comment.