Skip to content

Commit

Permalink
feat: regex pattern
Browse files Browse the repository at this point in the history
  • Loading branch information
vm-001 committed Feb 28, 2024
1 parent f1ca79a commit d38b2c7
Show file tree
Hide file tree
Showing 15 changed files with 305 additions and 50 deletions.
1 change: 1 addition & 0 deletions .github/workflows/examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ jobs:
run: |
lua examples/example.lua
lua examples/custom-matcher.lua
lua examples/regular-expression.lua
4 changes: 4 additions & 0 deletions .luacheckrc
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
unused_args = false
max_line_length = false
redefined = false

globals = {
"ngx",
}
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ bench:
RADIX_ROUTER_ROUTES=100000 RADIX_ROUTER_TIMES=10000000 $(CMD) benchmark/simple-variable.lua
RADIX_ROUTER_ROUTES=1000000 RADIX_ROUTER_TIMES=10000000 $(CMD) benchmark/simple-variable.lua
RADIX_ROUTER_ROUTES=100000 RADIX_ROUTER_TIMES=10000000 $(CMD) benchmark/simple-prefix.lua
RADIX_ROUTER_ROUTES=100000 RADIX_ROUTER_TIMES=1000000 $(CMD) benchmark/simple-regex.lua
RADIX_ROUTER_ROUTES=100000 RADIX_ROUTER_TIMES=1000000 $(CMD) benchmark/complex-variable.lua
RADIX_ROUTER_ROUTES=100000 RADIX_ROUTER_TIMES=10000000 $(CMD) benchmark/simple-variable-binding.lua
RADIX_ROUTER_TIMES=1000000 $(CMD) benchmark/github-routes.lua
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@ The router can be run in different runtimes such as Lua, LuaJIT, or OpenResty.

**Custom matcher:** The router has two efficient matchers built in, MethodMatcher(`method`) and HostMatcher(`host`). They can be disabled via `opts.matcher_names`. You can also add your custom matchers via `opts.matchers`. For example, an IpMatcher to evaluate whether the `ctx.ip` is matched with the `ips` of a route.

**Regular Expression:** You can define regex pattern in variables. a variable without regex pattern is treated as `[^/]+`.

- `/users/{id:\\d+}/profile-{year:\\d{4}}.{format:(html|pdf)}`

**Features in the roadmap**:

- Expression condition: defines custom matching conditions by using expression language.
- Regex in variable


## 📖 Getting started

Expand Down
33 changes: 33 additions & 0 deletions benchmark/simple-regex.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
local Router = require "radix-router"
local utils = require "benchmark.utils"

local route_n = os.getenv("RADIX_ROUTER_ROUTES") or 1000 * 100
local times = os.getenv("RADIX_ROUTER_TIMES") or 1000 * 1000 * 10

local router
do
local routes = {}
for i = 1, route_n do
routes[i] = { paths = { string.format("/%d/{name:[^/]+}", i) }, handler = i }
end
router = Router.new(routes)
end

local rss_mb = utils.get_rss()

local path = "/1/a"
local elapsed = utils.timing(function()
for _ = 1, times do
router:match(path)
end
end)

utils.print_result({
title = "regex",
routes = route_n,
times = times,
elapsed = elapsed,
benchmark_path = path,
benchmark_handler = router:match(path),
rss = rss_mb,
})
12 changes: 12 additions & 0 deletions examples/regular-expression.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
local Router = require "radix-router"
local router, err = Router.new({
{
paths = { "/users/{id:\\d+}/profile-{year:\\d{4}}.{format:(html|pdf)}" },
handler = "1"
},
})
if not router then
error("failed to create router: " .. err)
end

assert("1" == router:match("/users/100/profile-2024.pdf"))
2 changes: 1 addition & 1 deletion radix-router-dev-1.rockspec
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ description = {
}

dependencies = {
"lua >= 5.1, < 5.5"
"lrexlib-pcre2",
}

build = {
Expand Down
40 changes: 39 additions & 1 deletion spec/parser_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ describe("parser", function()
["/aa/{var1}/cc/{var2}"] = { "/aa/", "{var1}", "/cc/", "{var2}" },
["/user/profile.{format}"] = { "/user/profile.", "{format}" },
["/user/{filename}.{format}"] = { "/user/", "{filename}", ".", "{format}" },
["/aa/{name:[0-9]+}/{*suffix}"] = { "/aa/", "{name:[0-9]+}", "/", "{*suffix}" }
["/aa/{name:[0-9]+}/{*suffix}"] = { "/aa/", "{name:[0-9]+}", "/", "{*suffix}" },
["/user/{id:\\d+}/profile-{year:\\d{4}}.{format:(html|pdf)}"] = { "/user/", "{id:\\d+}", "/profile-", "{year:\\d{4}}", ".", "{format:(html|pdf)}" },
}

for path, expected_tokens in pairs(tests) do
Expand Down Expand Up @@ -63,5 +64,42 @@ describe("parser", function()
assert.same(test.params, params, "assertion failed: " .. i)
end
end)
it("compile_regex()", function()
local tests = {
{
path = "/a/b/c",
regex = "^\\Q/a/b/c\\E$"
},
{
path = "/a/{b}/c/{d}",
regex = "^\\Q/a/\\E[^/]+\\Q/c/\\E[^/]+$"
},
{
path = "/a/{b:\\d+}/c/{d:\\d{3}}",
regex = "^\\Q/a/\\E\\d+\\Q/c/\\E\\d{3}$"
},
{
path = "/a/{*catchall}",
regex = "^\\Q/a/\\E.*$"
},
{
path = "/a/{b}/c/{*catchall}",
regex = "^\\Q/a/\\E[^/]+\\Q/c/\\E.*$"
},
{
path = "/a/{b:[a-z]+}/c/{*catchall}",
regex = "^\\Q/a/\\E[a-z]+\\Q/c/\\E.*$"
},
{
path = "/users/{id:\\d+}/profile-{year:\\d{4}}.{format:(html|pdf)}",
regex = "^\\Q/users/\\E\\d+\\Q/profile-\\E\\d{4}\\Q.\\E(html|pdf)$"
}
}
for i, test in pairs(tests) do
local parser = Parser.new("default")
local regex = parser:update(test.path):compile_regex()
assert.same(test.regex, regex, "assertion failed: " .. i)
end
end)
end)
end)
46 changes: 45 additions & 1 deletion spec/router_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ describe("Router", function()

local router, err = Router.new({}, { matcher_names = { "inexistent" } })
assert.is_nil(router)
assert.equal("invalid matcher name: inexistent", err)
assert.equal("invalid args opts: invalid matcher name: inexistent", err)
end)
end)
describe("match", function()
Expand Down Expand Up @@ -480,6 +480,50 @@ describe("Router", function()
assert.same({ cat = "suffix" }, binding)
end)
end)
describe("regex", function()
it("sanity", function()
local router = Router.new({
{
paths = { "/a/{b:\\d{3}}/c" },
handler = "/a/{b:\\d{3}}/c",
},
{
paths = { "/a/{b:\\d+}/c" },
handler = "/a/{b:\\d+}/c",
},
{
paths = { "/a/{b:[a-z]+}/c" },
handler = "/a/{b:[a-z]+}/c",
},
{
paths = { "/a/{b:[^/]+}/c" },
handler = "/a/{b:[^/]+}/c",
},
{
paths = { "/users/{id:\\d+}/profile-{year:\\d{4}}.{format:(html|pdf)}" },
handler = "1",
},
{
paths = { "/escape/{var}/{var1:[a-z]+}|{var2:[A-Z]+}|{var3:\\d+}|{var4:(html|pdf)}" },
handler = "2",
}
})
assert.equal("/a/{b:\\d+}/c", router:match("/a/2024/c"))
assert.equal("/a/{b:\\d{3}}/c", router:match("/a/123/c"))
assert.equal("/a/{b:[a-z]+}/c", router:match("/a/abc/c"))
assert.equal("/a/{b:[^/]+}/c", router:match("/a/abc0/c"))

-- /users/{id:\\d+}/profile-{year:\\d{4}}.{format:html|pdf}
assert.equal("1", router:match("/users/123/profile-2024.html"))
assert.equal("1", router:match("/users/123/profile-2024.pdf"))
assert.equal(nil, router:match("/users/abc/profile-2024.html"))
assert.equal(nil, router:match("/users/123/profile-123.html"))
assert.equal(nil, router:match("/users/123/profile-2024.jpg"))

-- /escape/{var}/{var1:[a-z]+}|{var2:[A-Z]+}|{var3:\\d+}|{var4:(html|pdf)}
assert.equal("2", router:match("/escape/var/aaa|AAA|111|html"))
end)
end)
describe("matching order", function()
it("first registered first match", function()
local router = Router.new({
Expand Down
14 changes: 14 additions & 0 deletions spec/utils_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,18 @@ describe("utils", function()
assert.equal(0, utils.lcp("", "/abcd"))
assert.equal(0, utils.lcp("a", "c"))
end)
pending("is_digit()", function()
assert.is_true(utils.is_digit(string.byte("0")))
assert.is_true(utils.is_digit(string.byte("1")))
assert.is_true(utils.is_digit(string.byte("2")))
assert.is_true(utils.is_digit(string.byte("3")))
assert.is_true(utils.is_digit(string.byte("4")))
assert.is_true(utils.is_digit(string.byte("5")))
assert.is_true(utils.is_digit(string.byte("6")))
assert.is_true(utils.is_digit(string.byte("7")))
assert.is_true(utils.is_digit(string.byte("8")))
assert.is_true(utils.is_digit(string.byte("9")))
assert.is_false(utils.is_digit(string.byte("/")))
assert.is_false(utils.is_digit(string.byte(":")))
end)
end)
2 changes: 1 addition & 1 deletion src/parser/parser.lua
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ local parsers = {
function Parser.new(style)
local parser = parsers[style]
if not parser then
return nil, "invalid style: " .. style
return nil, "unknown parser style: " .. style
end

return parser.new()
Expand Down
90 changes: 80 additions & 10 deletions src/parser/style/default.lua
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ function _M:reset()
self.anchor = 1
self.pos = 1
self.state = nil
self.bracket_depth = 0
end


Expand All @@ -58,7 +59,8 @@ function _M:next()
local char, token, token_type
while self.pos <= self.path_n do
char = byte(self.path, self.pos)
--print("pos: " .. self.pos .. "(" .. string.char(char) .. ")")
--local char_str = string.char(char)
--print("pos: " .. self.pos .. "(" .. char_str .. ")")
if self.state == nil or self.state == STATES.static then
if char == BYTE_LEFT_BRACKET then
if self.state == STATES.static then
Expand All @@ -67,12 +69,18 @@ function _M:next()
self.anchor = self.pos
end
self.state = STATES.variable_start
self.bracket_depth = 1
else
self.state = STATES.static
end
elseif self.state == STATES.variable_start then
if char == BYTE_RIGHT_BRACKET then
self.state = STATES.variable_end
if char == BYTE_LEFT_BRACKET then
self.bracket_depth = self.bracket_depth + 1
elseif char == BYTE_RIGHT_BRACKET then
self.bracket_depth = self.bracket_depth - 1
if self.bracket_depth == 0 then
self.state = STATES.variable_end
end
end
elseif self.state == STATES.variable_end then
self.state = STATES.static
Expand All @@ -93,6 +101,7 @@ function _M:next()
return token, self.token_type(token)
end


function _M:parse()
self:reset()

Expand All @@ -108,6 +117,7 @@ function _M:parse()
return tokens
end


function _M.token_type(token)
if byte(token) == BYTE_LEFT_BRACKET and
byte(token, #token) == BYTE_RIGHT_BRACKET then
Expand All @@ -120,15 +130,39 @@ function _M.token_type(token)
return TOKEN_TYPES.literal
end

function _M.is_dynamic(path)
local patn_n = #path
for i = 1, patn_n do
local char = byte(path, i)
if char == BYTE_LEFT_BRACKET or char == BYTE_RIGHT_BRACKET then
return true

local function parse_token_regex(token)
for i = 1, #token do
if byte(token, i) == BYTE_COLON then
return sub(token, i + 1, -2)
end
end
return false
return nil
end


-- compile path to regex pattern
function _M:compile_regex()
local tokens = { "^" }

local token, token_type = self:next()
while token do
if token_type == TOKEN_TYPES.variable then
local pattern = parse_token_regex(token) or "[^/]+"
table.insert(tokens, pattern)
elseif token_type == TOKEN_TYPES.catchall then
table.insert(tokens, ".*")
else
-- quote the literal token
table.insert(tokens, "\\Q")
table.insert(tokens, token)
table.insert(tokens, "\\E")
end
token, token_type = self:next()
end
table.insert(tokens, "$")

return table.concat(tokens)
end

function _M:params()
Expand Down Expand Up @@ -240,4 +274,40 @@ function _M:bind_params(req_path, req_path_n, params, trailing_slash_mode)
end
end


local function contains_regex(path)
local bracket_depth = 0

for i = 1, #path do
local char = byte(path, i)
if char == BYTE_LEFT_BRACKET then
bracket_depth = bracket_depth + 1
elseif char == BYTE_RIGHT_BRACKET then
bracket_depth = bracket_depth - 1
elseif char == BYTE_COLON and bracket_depth == 1 then
-- regex syntax {var:[^/]+}
-- return true only if the colon is in the first depth
return true
end
end

return false
end


local function is_dynamic(path)
local patn_n = #path
for i = 1, patn_n do
local char = byte(path, i)
if char == BYTE_LEFT_BRACKET or char == BYTE_RIGHT_BRACKET then
return true
end
end
return false
end


_M.contains_regex = contains_regex
_M.is_dynamic = is_dynamic

return _M
2 changes: 1 addition & 1 deletion src/route.lua
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ local Route = {}
local mt = { __index = Route }


function Route.new(route, _)
function Route.new(route)
if route.handler == nil then
return nil, "handler must not be nil"
end
Expand Down
Loading

0 comments on commit d38b2c7

Please sign in to comment.