Skip to content

Commit

Permalink
feat(router): support segment based matching in expressions flavor (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
chronolaw committed Jan 23, 2024
1 parent 80e292b commit 1e7295f
Show file tree
Hide file tree
Showing 8 changed files with 311 additions and 5 deletions.
2 changes: 1 addition & 1 deletion .requirements
Expand Up @@ -10,7 +10,7 @@ LUA_KONG_NGINX_MODULE=4fbc3ddc7dcbc706ed286b95344f3cb6da17e637 # 0.8.0
LUA_RESTY_LMDB=951926f20b674a0622236a0e331b359df1c02d9b # 1.3.0
LUA_RESTY_EVENTS=8448a92cec36ac04ea522e78f6496ba03c9b1fd8 # 0.2.0
LUA_RESTY_WEBSOCKET=60eafc3d7153bceb16e6327074e0afc3d94b1316 # 0.4.0
ATC_ROUTER=ac71b24ea5556b38b0f9903850ed666c36ad7843 # 1.4.1
ATC_ROUTER=ed489405575a07664e04305997f049a3e7ec3dde # 1.5.0

KONG_MANAGER=v3.5.0.0
NGX_WASM_MODULE=388d5720293f5091ccee1f859a42683fbfd14e7d # prerelease-0.2.0
Expand Down
2 changes: 1 addition & 1 deletion changelog/unreleased/kong/bump-atc-router.yml
@@ -1,3 +1,3 @@
message: Bumped atc-router from 1.2.0 to 1.4.1
message: Bumped atc-router from 1.2.0 to 1.5.0
type: dependency
scope: Core
@@ -0,0 +1,5 @@
message: |
Support `http.path.segments.*` field in expressions router flavor
which allows matching incoming request path by individual segment or ranges of segments.
type: feature
scope: Core
23 changes: 21 additions & 2 deletions kong/db/schema/entities/routes.lua
Expand Up @@ -4,20 +4,39 @@ local deprecation = require("kong.deprecation")

local validate_route
do
local ipairs = ipairs
local tonumber = tonumber
local re_match = ngx.re.match

local get_schema = require("kong.router.atc").schema
local get_expression = require("kong.router.compat").get_expression
local transform_expression = require("kong.router.expressions").transform_expression

local HTTP_PATH_SEGMENTS_PREFIX = "http.path.segments."
local HTTP_PATH_SEGMENTS_SUFFIX_REG = [[^(0|[1-9]\d*)(_([1-9]\d*))?$]]

-- works with both `traditional_compatiable` and `expressions` routes`
validate_route = function(entity)
local schema = get_schema(entity.protocols)
local exp = transform_expression(entity) or get_expression(entity)

local ok, err = router.validate(schema, exp)
if not ok then
local fields, err = router.validate(schema, exp)
if not fields then
return nil, "Router Expression failed validation: " .. err
end

for _, f in ipairs(fields) do
if f:find(HTTP_PATH_SEGMENTS_PREFIX, 1, true) then
local m = re_match(f:sub(#HTTP_PATH_SEGMENTS_PREFIX + 1),
HTTP_PATH_SEGMENTS_SUFFIX_REG, "jo")

if not m or (m[2] and tonumber(m[1]) >= tonumber(m[3])) then
return nil, "Router Expression failed validation: " ..
"illformed http.path.segments.* field"
end
end
end

return true
end
end
Expand Down
1 change: 1 addition & 0 deletions kong/router/atc.lua
Expand Up @@ -62,6 +62,7 @@ do
["String"] = {"net.protocol", "tls.sni",
"http.method", "http.host",
"http.path",
"http.path.segments.*",
"http.headers.*",
"http.queries.*",
},
Expand Down
71 changes: 70 additions & 1 deletion kong/router/fields.lua
Expand Up @@ -167,6 +167,15 @@ end -- is_http
if is_http then

local fmt = string.format
local ngx_null = ngx.null
local re_split = require("ngx.re").split


local HTTP_SEGMENTS_PREFIX = "http.path.segments."
local HTTP_SEGMENTS_PREFIX_LEN = #HTTP_SEGMENTS_PREFIX
local HTTP_SEGMENTS_REG_CTX = { pos = 2, } -- skip first '/'
local HTTP_SEGMENTS_OFFSET = 1


-- func => get_headers or get_uri_args
-- name => "headers" or "queries"
Expand Down Expand Up @@ -209,7 +218,67 @@ if is_http then

return params.queries[field:sub(PREFIX_LEN + 1)]
end
end

elseif field:sub(1, HTTP_SEGMENTS_PREFIX_LEN) == HTTP_SEGMENTS_PREFIX then
return function(params)
if not params.segments then
HTTP_SEGMENTS_REG_CTX.pos = 2 -- reset ctx, skip first '/'
params.segments = re_split(params.uri, "/", "jo", HTTP_SEGMENTS_REG_CTX)
end

local segments = params.segments

local range = field:sub(HTTP_SEGMENTS_PREFIX_LEN + 1)
local value = segments[range]

if value then
return value ~= ngx_null and value or nil
end

-- "/a/b/c" => 1="a", 2="b", 3="c"
-- http.path.segments.0 => params.segments[1 + 0] => a
-- http.path.segments.1_2 => b/c

local p = range:find("_", 1, true)

-- only one segment, e.g. http.path.segments.1

if not p then
local pos = tonumber(range)

value = pos and segments[HTTP_SEGMENTS_OFFSET + pos] or nil
segments[range] = value or ngx_null

return value
end

-- (pos1, pos2) defines a segment range, e.g. http.path.segments.1_2

local pos1 = tonumber(range:sub(1, p - 1))
local pos2 = tonumber(range:sub(p + 1))
local segs_count = #segments - HTTP_SEGMENTS_OFFSET

if not pos1 or not pos2 or
pos1 >= pos2 or pos1 > segs_count or pos2 > segs_count
then
segments[range] = ngx_null
return nil
end

local buf = buffer.new()

for p = pos1, pos2 - 1 do
buf:put(segments[HTTP_SEGMENTS_OFFSET + p], "/")
end
buf:put(segments[HTTP_SEGMENTS_OFFSET + pos2])

value = buf:get()
segments[range] = value

return value
end

end -- if prefix

-- others return nil
end
Expand Down
46 changes: 46 additions & 0 deletions spec/01-unit/01-db/01-schema/06-routes_spec.lua
Expand Up @@ -1561,4 +1561,50 @@ describe("routes schema (flavor = expressions)", function()
route = Routes:process_auto_fields(route, "insert")
assert.truthy(Routes:validate(route))
end)

it("http route supports http.path.segments.* fields", function()
local route = {
id = a_valid_uuid,
name = "my_route",
protocols = { "grpcs" },
expression = [[http.path.segments.0 == "foo" && http.path.segments.1 ^= "bar" && http.path.segments.20_30 ~ r#"x/y"#]],
priority = 100,
service = { id = another_uuid },
}
route = Routes:process_auto_fields(route, "insert")
assert.truthy(Routes:validate(route))
end)

it("fails if http route has invalid http.path.segments.* fields", function()
local r = {
id = a_valid_uuid,
name = "my_route",
protocols = { "http" },
priority = 100,
service = { id = another_uuid },
}

local wrong_expressions = {
[[http.path.segments. == "foo"]],
[[http.path.segments.abc == "foo"]],
[[http.path.segments.a_c == "foo"]],
[[http.path.segments.1_2_3 == "foo"]],
[[http.path.segments.1_ == "foo"]],
[[http.path.segments._1 == "foo"]],
[[http.path.segments.2_1 == "foo"]],
[[http.path.segments.1_1 == "foo"]],
[[http.path.segments.01_2 == "foo"]],
[[http.path.segments.001_2 == "foo"]],
[[http.path.segments.1_03 == "foo"]],
}

for _, exp in ipairs(wrong_expressions) do
r.expression = exp

local route = Routes:process_auto_fields(r, "insert")
local ok, errs = Routes:validate_insert(route)
assert.falsy(ok)
assert.truthy(errs["@entity"])
end
end)
end)
166 changes: 166 additions & 0 deletions spec/01-unit/08-router_spec.lua
Expand Up @@ -5391,5 +5391,171 @@ do
assert.same(ctx.route_match_cached, "pos")
end)
end)

describe("Router (flavor = " .. flavor .. ") [http]", function()
reload_router(flavor)

it("select() should match single http.segments.*", function()
local use_case = {
{
service = service,
route = {
id = "e8fb37f1-102d-461e-9c51-6608a6bb8101",
expression = [[http.path.segments.0 == "foo" && http.path.segments.1 == "bar"]],
priority = 100,
},
},
{
service = service,
route = {
id = "e8fb37f1-102d-461e-9c51-6608a6bb8102",
expression = [[http.path.segments.0 == "foo" && http.path.segments.2 ^= "baz"]],
priority = 200,
},
},
{
service = service,
route = {
id = "e8fb37f1-102d-461e-9c51-6608a6bb8103",
expression = [[http.path.segments.0 == "foo" && http.path.segments.3 ~ r#"\d+"#]],
priority = 300,
},
},
}

local router = assert(new_router(use_case))

local match_t = router:select("GET", "/foo/bar")
assert.truthy(match_t)
assert.same(use_case[1].route, match_t.route)

local match_t = router:select("GET", "/foo/bar/bazxxx")
assert.truthy(match_t)
assert.same(use_case[2].route, match_t.route)

local match_t = router:select("GET", "/foo/bar/baz/12345")
assert.truthy(match_t)
assert.same(use_case[3].route, match_t.route)

local match_t = router:select("GET", "/foo/xxx")
assert.falsy(match_t)
end)

it("select() should match range http.segments.*", function()
local use_case = {
{
service = service,
route = {
id = "e8fb37f1-102d-461e-9c51-6608a6bb8101",
expression = [[http.path.segments.0_1 ~ r#"\d+/\w+"#]],
priority = 100,
},
},
{
service = service,
route = {
id = "e8fb37f1-102d-461e-9c51-6608a6bb8102",
expression = [[http.path.segments.1_3 == r#"xxx/yyy/zzz"#]],
priority = 100,
},
},
}

local router = assert(new_router(use_case))

local match_t = router:select("GET", "/123/foo/bar")
assert.truthy(match_t)
assert.same(use_case[1].route, match_t.route)

local match_t = router:select("GET", "/123/hello-world/bar")
assert.truthy(match_t)
assert.same(use_case[1].route, match_t.route)

local match_t = router:select("GET", "/foo/xxx/yyy/zzz/bar")
assert.truthy(match_t)
assert.same(use_case[2].route, match_t.route)
end)

it("select() accepts but does not match wrong http.segments.*", function()
local use_case = {
{
service = service,
route = {
id = "e8fb37f1-102d-461e-9c51-6608a6bb8101",
expression = [[http.path.segments.4_1 == r#"foo"#]],
priority = 100,
},
},
{
service = service,
route = {
id = "e8fb37f1-102d-461e-9c51-6608a6bb8102",
expression = [[http.path.segments.10_11 == r#"foo/bar"#]],
priority = 100,
},
},
}

local router = assert(new_router(use_case))

local match_t = router:select("GET", "/foo/bar")
assert.falsy(match_t)
end)

it("exec() should hit cache with http.segments.*", function()
local use_case = {
{
service = service,
route = {
id = "e8fb37f1-102d-461e-9c51-6608a6bb8101",
expression = [[http.path.segments.0 == "foo" && http.path.segments.1 == "bar"]],
priority = 100,
},
},
{
service = service,
route = {
id = "e8fb37f1-102d-461e-9c51-6608a6bb8102",
expression = [[http.path.segments.1_3 == r#"xxx/yyy/zzz"#]],
priority = 100,
},
},
}

local router = assert(new_router(use_case))

local ctx = {}
local _ngx = mock_ngx("GET", "/foo/bar", { a = "1", })
router._set_ngx(_ngx)

-- first match
local match_t = router:exec(ctx)
assert.truthy(match_t)
assert.same(use_case[1].route, match_t.route)
assert.falsy(ctx.route_match_cached)

-- cache hit pos
local match_t = router:exec(ctx)
assert.truthy(match_t)
assert.same(use_case[1].route, match_t.route)
assert.same(ctx.route_match_cached, "pos")

local ctx = {}
local _ngx = mock_ngx("GET", "/foo/xxx/yyy/zzz/bar", { a = "1", })
router._set_ngx(_ngx)

-- first match
local match_t = router:exec(ctx)
assert.truthy(match_t)
assert.same(use_case[2].route, match_t.route)
assert.falsy(ctx.route_match_cached)

-- cache hit pos
local match_t = router:exec(ctx)
assert.truthy(match_t)
assert.same(use_case[2].route, match_t.route)
assert.same(ctx.route_match_cached, "pos")
end)
end)
end -- local flavor = "expressions"

0 comments on commit 1e7295f

Please sign in to comment.