diff --git a/.requirements b/.requirements index c7388ffe885..86b8e6e5fbd 100644 --- a/.requirements +++ b/.requirements @@ -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 diff --git a/changelog/unreleased/kong/bump-atc-router.yml b/changelog/unreleased/kong/bump-atc-router.yml index 2013fd9dda6..4dc86d579a7 100644 --- a/changelog/unreleased/kong/bump-atc-router.yml +++ b/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 diff --git a/changelog/unreleased/kong/support_http_path_segments_field.yml b/changelog/unreleased/kong/support_http_path_segments_field.yml new file mode 100644 index 00000000000..178eedc3e9c --- /dev/null +++ b/changelog/unreleased/kong/support_http_path_segments_field.yml @@ -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 diff --git a/kong/db/schema/entities/routes.lua b/kong/db/schema/entities/routes.lua index 621b08cfe70..3a9dfe8a109 100644 --- a/kong/db/schema/entities/routes.lua +++ b/kong/db/schema/entities/routes.lua @@ -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 diff --git a/kong/router/atc.lua b/kong/router/atc.lua index f1e448ea96c..b16619650e6 100644 --- a/kong/router/atc.lua +++ b/kong/router/atc.lua @@ -62,6 +62,7 @@ do ["String"] = {"net.protocol", "tls.sni", "http.method", "http.host", "http.path", + "http.path.segments.*", "http.headers.*", "http.queries.*", }, diff --git a/kong/router/fields.lua b/kong/router/fields.lua index 082bd6db9b0..21dfc244f14 100644 --- a/kong/router/fields.lua +++ b/kong/router/fields.lua @@ -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" @@ -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 diff --git a/spec/01-unit/01-db/01-schema/06-routes_spec.lua b/spec/01-unit/01-db/01-schema/06-routes_spec.lua index 62e55db628a..510302650d2 100644 --- a/spec/01-unit/01-db/01-schema/06-routes_spec.lua +++ b/spec/01-unit/01-db/01-schema/06-routes_spec.lua @@ -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) diff --git a/spec/01-unit/08-router_spec.lua b/spec/01-unit/08-router_spec.lua index d7a52af5541..b9aa912c8e8 100644 --- a/spec/01-unit/08-router_spec.lua +++ b/spec/01-unit/08-router_spec.lua @@ -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"