This is an automated email from the ASF dual-hosted git repository.
nic443 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git
The following commit(s) were added to refs/heads/master by this push:
new 5a3880666 feat(admin): make /configs/validate available in all modes
(#13220)
5a3880666 is described below
commit 5a38806667e408a9e94bd21deeed0bfcdd9953ab
Author: Nic <[email protected]>
AuthorDate: Wed Apr 15 14:19:55 2026 +0800
feat(admin): make /configs/validate available in all modes (#13220)
---
apisix/admin/config_validate.lua | 263 ++++++++++++++++
apisix/admin/init.lua | 12 +
apisix/admin/standalone.lua | 198 +-----------
t/admin/config-validate.t | 658 +++++++++++++++++++++++++++++++++++++++
t/admin/standalone.spec.ts | 12 +-
5 files changed, 944 insertions(+), 199 deletions(-)
diff --git a/apisix/admin/config_validate.lua b/apisix/admin/config_validate.lua
new file mode 100644
index 000000000..b4d4b090f
--- /dev/null
+++ b/apisix/admin/config_validate.lua
@@ -0,0 +1,263 @@
+--
+-- Licensed to the Apache Software Foundation (ASF) under one or more
+-- contributor license agreements. See the NOTICE file distributed with
+-- this work for additional information regarding copyright ownership.
+-- The ASF licenses this file to You under the Apache License, Version 2.0
+-- (the "License"); you may not use this file except in compliance with
+-- the License. You may obtain a copy of the License at
+--
+-- http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing, software
+-- distributed under the License is distributed on an "AS IS" BASIS,
+-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+-- See the License for the specific language governing permissions and
+-- limitations under the License.
+--
+
+--- Batch configuration validation module.
+-- Validates APISIX declarative configurations (routes, services, consumers,
etc.)
+-- including resource-level JSON Schema and plugin check_schema() advanced
validation.
+-- Used by both standalone mode and etcd mode via POST
/apisix/admin/configs/validate.
+
+local type = type
+local pairs = pairs
+local ipairs = ipairs
+local tostring = tostring
+local pcall = pcall
+local str_find = string.find
+local str_sub = string.sub
+local table_insert = table.insert
+local yaml = require("lyaml")
+local core = require("apisix.core")
+local tbl_deepcopy = require("apisix.core.table").deepcopy
+local constants = require("apisix.constants")
+
+local _M = {}
+
+-- 1.5 MiB, same as other Admin API handlers
+local MAX_REQ_BODY = 1024 * 1024 * 1.5
+
+local resources = {
+ routes = require("apisix.admin.routes"),
+ services = require("apisix.admin.services"),
+ upstreams = require("apisix.admin.upstreams"),
+ consumers = require("apisix.admin.consumers"),
+ credentials = require("apisix.admin.credentials"),
+ schema = require("apisix.admin.schema"),
+ ssls = require("apisix.admin.ssl"),
+ plugins = require("apisix.admin.plugins"),
+ protos = require("apisix.admin.proto"),
+ global_rules = require("apisix.admin.global_rules"),
+ stream_routes = require("apisix.admin.stream_routes"),
+ plugin_metadata = require("apisix.admin.plugin_metadata"),
+ plugin_configs = require("apisix.admin.plugin_config"),
+ consumer_groups = require("apisix.admin.consumer_group"),
+ secrets = require("apisix.admin.secrets"),
+}
+
+local CONF_VERSION_KEY_SUFFIX = "_conf_version"
+local ALL_RESOURCE_KEYS = {}
+for dir in pairs(constants.HTTP_ETCD_DIRECTORY) do
+ local key = str_sub(dir, 2)
+ ALL_RESOURCE_KEYS[key] = key .. CONF_VERSION_KEY_SUFFIX
+end
+for dir in pairs(constants.STREAM_ETCD_DIRECTORY) do
+ local key = str_sub(dir, 2)
+ ALL_RESOURCE_KEYS[key] = key .. CONF_VERSION_KEY_SUFFIX
+end
+
+
+local function check_duplicate(item, key, id_set)
+ local identifier, identifier_type
+ if key == "consumers" then
+ identifier = item.id or item.username
+ identifier_type = item.id and "credential id" or "username"
+ else
+ identifier = item.id
+ identifier_type = "id"
+ end
+
+ if not identifier then
+ return false
+ end
+
+ if id_set[identifier] then
+ return true, "found duplicate " .. identifier_type .. " " ..
identifier .. " in " .. key
+ end
+ id_set[identifier] = true
+ return false
+end
+
+
+local function check_conf(checker, schema, item, typ)
+ if not checker then
+ return true
+ end
+ local str_id = tostring(item.id)
+ if typ == "consumers" and
+ core.string.find(str_id, "/credentials/") then
+ local credential_checker = resources.credentials.checker
+ local credential_schema = resources.credentials.schema
+ return credential_checker(item.id, item, false, credential_schema, {
+ skip_references_check = true,
+ })
+ end
+
+ local secret_type
+ if typ == "secrets" then
+ local idx = str_find(str_id or "", "/")
+ if not idx then
+ return false, {
+ error_msg = "invalid secret id: " .. (str_id or "")
+ }
+ end
+ secret_type = str_sub(str_id, 1, idx - 1)
+ end
+ return checker(item.id, item, false, schema, {
+ secret_type = secret_type,
+ skip_references_check = true,
+ })
+end
+
+
+function _M.validate_configuration(req_body, collect_all_errors)
+ local is_valid = true
+ local validation_results = {}
+
+ for key, conf_version_key in pairs(ALL_RESOURCE_KEYS) do
+ local items = req_body[key]
+ local resource = resources[key] or {}
+
+ -- Validate conf_version_key if present
+ local new_conf_version = req_body[conf_version_key]
+ if new_conf_version and type(new_conf_version) ~= "number" then
+ if not collect_all_errors then
+ return false, conf_version_key .. " must be a number"
+ end
+ is_valid = false
+ table_insert(validation_results, {
+ resource_type = key,
+ error = conf_version_key .. " must be a number, got " ..
type(new_conf_version)
+ })
+ end
+
+ if items and #items > 0 then
+ local item_schema = resource.schema
+ local item_checker = resource.checker
+ local id_set = {}
+
+ for index, item in ipairs(items) do
+ local item_temp = tbl_deepcopy(item)
+ local ok, valid, err = pcall(check_conf, item_checker,
item_schema, item_temp, key)
+ if not ok then
+ -- checker threw an error
+ err = valid -- pcall returns (false, error_message)
+ valid = false
+ end
+ if not valid then
+ local err_msg = type(err) == "table" and err.error_msg or
tostring(err)
+ local resource_id = item.id or item.username or ""
+
+ if not collect_all_errors then
+ return false, err_msg
+ end
+ is_valid = false
+ table_insert(validation_results, {
+ resource_type = key,
+ resource_id = resource_id,
+ index = index - 1,
+ error = err_msg
+ })
+ end
+
+ -- check for duplicate IDs
+ local duplicated, dup_err = check_duplicate(item, key, id_set)
+ if duplicated then
+ if not collect_all_errors then
+ return false, dup_err
+ end
+ is_valid = false
+ table_insert(validation_results, {
+ resource_type = key,
+ resource_id = item.id or item.username or "",
+ index = index - 1,
+ error = dup_err
+ })
+ end
+ end
+ end
+ end
+
+ if collect_all_errors then
+ return is_valid, validation_results
+ end
+
+ return is_valid, nil
+end
+
+
+function _M.validate()
+ local content_type = core.request.header(nil, "content-type") or
"application/json"
+ local req_body, err = core.request.get_body(MAX_REQ_BODY)
+ if err then
+ return core.response.exit(400, {error_msg = "invalid request body: "
.. err})
+ end
+
+ if not req_body or #req_body <= 0 then
+ return core.response.exit(400, {error_msg = "invalid request body:
empty request body"})
+ end
+
+ local data
+ if core.string.has_prefix(content_type, "application/yaml") then
+ local ok, result = pcall(yaml.load, req_body, { all = false })
+ if not ok or type(result) ~= "table" then
+ err = "invalid yaml request body"
+ else
+ data = result
+ end
+ else
+ data, err = core.json.decode(req_body)
+ end
+
+ if err then
+ core.log.warn("invalid request body: ", req_body, " err: ", err)
+ return core.response.exit(400, {error_msg = "invalid request body: "
.. err})
+ end
+
+ local ok, valid, validation_results = pcall(_M.validate_configuration,
data, true)
+ if not ok then
+ core.log.warn("unexpected error during validation: ", tostring(valid))
+ return core.response.exit(400, {
+ error_msg = "Configuration validation failed",
+ errors = {{error = tostring(valid)}}
+ })
+ end
+ if not valid then
+ -- Ensure all error values in validation_results are JSON-serializable
+ for i, item in ipairs(validation_results) do
+ if type(item.error) ~= "string" then
+ validation_results[i].error = tostring(item.error)
+ end
+ end
+ return core.response.exit(400, {
+ error_msg = "Configuration validation failed",
+ errors = validation_results
+ })
+ end
+
+ return core.response.exit(200, {})
+end
+
+
+function _M.get_all_resource_keys()
+ return ALL_RESOURCE_KEYS
+end
+
+
+function _M.get_resources()
+ return resources
+end
+
+
+return _M
diff --git a/apisix/admin/init.lua b/apisix/admin/init.lua
index de10d92d0..a040420f6 100644
--- a/apisix/admin/init.lua
+++ b/apisix/admin/init.lua
@@ -20,6 +20,7 @@ local get_uri_args = ngx.req.get_uri_args
local route = require("apisix.utils.router")
local plugin = require("apisix.plugin")
local standalone = require("apisix.admin.standalone")
+local config_validate = require("apisix.admin.config_validate")
local v3_adapter = require("apisix.admin.v3_adapter")
local utils = require("apisix.admin.utils")
local ngx = ngx
@@ -423,6 +424,12 @@ local function standalone_run()
end
+local function validate_configs()
+ set_ctx_and_check_token()
+ return config_validate.validate()
+end
+
+
local http_head_route = {
paths = [[/apisix/admin]],
methods = {"HEAD"},
@@ -432,6 +439,11 @@ local http_head_route = {
local uri_route = {
http_head_route,
+ {
+ paths = [[/apisix/admin/configs/validate]],
+ methods = {"POST"},
+ handler = validate_configs,
+ },
{
paths = [[/apisix/admin/*]],
methods = {"GET", "PUT", "POST", "DELETE", "PATCH"},
diff --git a/apisix/admin/standalone.lua b/apisix/admin/standalone.lua
index be08c6b82..46f9a8001 100644
--- a/apisix/admin/standalone.lua
+++ b/apisix/admin/standalone.lua
@@ -17,35 +17,19 @@ local type = type
local pairs = pairs
local ipairs = ipairs
local str_lower = string.lower
-local str_find = string.find
-local str_sub = string.sub
-local tostring = tostring
local ngx = ngx
-local pcall = pcall
local ngx_time = ngx.time
local get_method = ngx.req.get_method
local shared_dict = ngx.shared["standalone-config"]
local timer_every = ngx.timer.every
local exiting = ngx.worker.exiting
-local table_insert = table.insert
local yaml = require("lyaml")
local events = require("apisix.events")
local core = require("apisix.core")
local config_yaml = require("apisix.core.config_yaml")
-local tbl_deepcopy = require("apisix.core.table").deepcopy
-local constants = require("apisix.constants")
-
--- combine all resources that using in http and stream substreams as one
constant
-local CONF_VERSION_KEY_SUFFIX = "_conf_version"
-local ALL_RESOURCE_KEYS = {}
-for dir in pairs(constants.HTTP_ETCD_DIRECTORY) do
- local key = str_sub(dir, 2)
- ALL_RESOURCE_KEYS[key] = key .. CONF_VERSION_KEY_SUFFIX
-end
-for dir in pairs(constants.STREAM_ETCD_DIRECTORY) do
- local key = str_sub(dir, 2)
- ALL_RESOURCE_KEYS[key] = key .. CONF_VERSION_KEY_SUFFIX
-end
+local config_validate = require("apisix.admin.config_validate")
+
+local ALL_RESOURCE_KEYS = config_validate.get_all_resource_keys()
local EVENT_UPDATE = "standalone-api-configuration-update"
local NOT_FOUND_ERR = "not found"
@@ -56,41 +40,6 @@ local METADATA_DIGEST = "X-Digest"
local _M = {}
-local resources = {
- routes = require("apisix.admin.routes"),
- services = require("apisix.admin.services"),
- upstreams = require("apisix.admin.upstreams"),
- consumers = require("apisix.admin.consumers"),
- credentials = require("apisix.admin.credentials"),
- schema = require("apisix.admin.schema"),
- ssls = require("apisix.admin.ssl"),
- plugins = require("apisix.admin.plugins"),
- protos = require("apisix.admin.proto"),
- global_rules = require("apisix.admin.global_rules"),
- stream_routes = require("apisix.admin.stream_routes"),
- plugin_metadata = require("apisix.admin.plugin_metadata"),
- plugin_configs = require("apisix.admin.plugin_config"),
- consumer_groups = require("apisix.admin.consumer_group"),
- secrets = require("apisix.admin.secrets"),
-}
-
-local function check_duplicate(item, key, id_set)
- local identifier, identifier_type
- if key == "consumers" then
- identifier = item.id or item.username
- identifier_type = item.id and "credential id" or "username"
- else
- identifier = item.id
- identifier_type = "id"
- end
-
- if id_set[identifier] then
- return true, "found duplicate " .. identifier_type .. " " ..
identifier .. " in " .. key
- end
- id_set[identifier] = true
- return false
-end
-
local function get_config()
local config = shared_dict:get("config")
if not config then
@@ -126,144 +75,7 @@ local function update_and_broadcast_config(apisix_yaml)
return events:post(EVENT_UPDATE, EVENT_UPDATE)
end
-local function check_conf(checker, schema, item, typ)
- if not checker then
- return true
- end
- local str_id = tostring(item.id)
- if typ == "consumers" and
- core.string.find(str_id, "/credentials/") then
- local credential_checker = resources.credentials.checker
- local credential_schema = resources.credentials.schema
- return credential_checker(item.id, item, false, credential_schema, {
- skip_references_check = true,
- })
- end
-
- local secret_type
- if typ == "secrets" then
- local idx = str_find(str_id or "", "/")
- if not idx then
- return false, {
- error_msg = "invalid secret id: " .. (str_id or "")
- }
- end
- secret_type = str_sub(str_id, 1, idx - 1)
- end
- return checker(item.id, item, false, schema, {
- secret_type = secret_type,
- skip_references_check = true,
- })
-end
-
-
-local function validate_configuration(req_body, collect_all_errors)
- local is_valid = true
- local validation_results = {}
-
- for key, conf_version_key in pairs(ALL_RESOURCE_KEYS) do
- local items = req_body[key]
- local resource = resources[key] or {}
-
- -- Validate conf_version_key if present
- local new_conf_version = req_body[conf_version_key]
- if new_conf_version and type(new_conf_version) ~= "number" then
- if not collect_all_errors then
- return false, conf_version_key .. " must be a number"
- end
- is_valid = false
- table_insert(validation_results, {
- resource_type = key,
- error = conf_version_key .. " must be a number, got " ..
type(new_conf_version)
- })
- end
-
- if items and #items > 0 then
- local item_schema = resource.schema
- local item_checker = resource.checker
- local id_set = {}
-
- for index, item in ipairs(items) do
- local item_temp = tbl_deepcopy(item)
- local valid, err = check_conf(item_checker, item_schema,
item_temp, key)
- if not valid then
- local err_prefix = "invalid " .. key .. " at index " ..
(index - 1) .. ", err: "
- local err_msg = type(err) == "table" and err.error_msg or
err
- local error_msg = err_prefix .. err_msg
-
- if not collect_all_errors then
- return false, error_msg
- end
- is_valid = false
- table_insert(validation_results, {
- resource_type = key,
- index = index - 1,
- error = error_msg
- })
- end
-
- -- check for duplicate IDs
- local duplicated, dup_err = check_duplicate(item, key, id_set)
- if duplicated then
- if not collect_all_errors then
- return false, dup_err
- end
- is_valid = false
- table_insert(validation_results, {
- resource_type = key,
- index = index - 1,
- error = dup_err
- })
- end
- end
- end
- end
-
- if collect_all_errors then
- return is_valid, validation_results
- end
-
- return is_valid, nil
-end
-
-local function validate(ctx)
- local content_type = core.request.header(nil, "content-type") or
"application/json"
- local req_body, err = core.request.get_body()
- if err then
- return core.response.exit(400, {error_msg = "invalid request body: "
.. err})
- end
-
- if not req_body or #req_body <= 0 then
- return core.response.exit(400, {error_msg = "invalid request body:
empty request body"})
- end
-
- local data
- if core.string.has_prefix(content_type, "application/yaml") then
- local ok, result = pcall(yaml.load, req_body, { all = false })
- if not ok or type(result) ~= "table" then
- err = "invalid yaml request body"
- else
- data = result
- end
- else
- data, err = core.json.decode(req_body)
- end
-
- if err then
- core.log.error("invalid request body: ", req_body, " err: ", err)
- return core.response.exit(400, {error_msg = "invalid request body: "
.. err})
- end
-
- local valid, validation_results = validate_configuration(data, true)
- if not valid then
- return core.response.exit(400, {
- error_msg = "Configuration validation failed",
- errors = validation_results
- })
- end
-
- return core.response.exit(200)
-end
+local validate_configuration = config_validate.validate_configuration
local function update(ctx)
-- check digest header existence
@@ -437,7 +249,7 @@ function _M.run()
if method == "post" then
local path = ctx.var.uri
if path == "/apisix/admin/configs/validate" then
- return validate(ctx)
+ return config_validate.validate()
else
return core.response.exit(404, {error_msg = "Not found"})
end
diff --git a/t/admin/config-validate.t b/t/admin/config-validate.t
new file mode 100644
index 000000000..0226a9c17
--- /dev/null
+++ b/t/admin/config-validate.t
@@ -0,0 +1,658 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+no_long_string();
+no_root_location();
+no_shuffle();
+log_level("warn");
+
+add_block_preprocessor(sub {
+ my ($block) = @_;
+
+ if (!$block->request) {
+ $block->set_value("request", "GET /t");
+ }
+});
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: validate configs - success with valid route
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ [[{
+ "routes": [
+ {
+ "id": "r1",
+ "uri": "/test",
+ "upstream": {
+ "nodes": {"127.0.0.1:1980": 1},
+ "type": "roundrobin"
+ }
+ }
+ ]
+ }]]
+ )
+
+ ngx.status = code
+ }
+}
+--- error_code: 200
+
+
+
+=== TEST 2: validate configs - success with multiple resource types
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ [[{
+ "routes": [
+ {
+ "id": "r1",
+ "uri": "/test",
+ "upstream": {
+ "nodes": {"127.0.0.1:1980": 1},
+ "type": "roundrobin"
+ }
+ }
+ ],
+ "services": [
+ {
+ "id": "svc-1",
+ "upstream": {
+ "nodes": {"127.0.0.1:1980": 1},
+ "type": "roundrobin"
+ }
+ }
+ ],
+ "upstreams": [
+ {
+ "id": "ups-1",
+ "nodes": {"127.0.0.1:1980": 1},
+ "type": "roundrobin"
+ }
+ ]
+ }]]
+ )
+
+ ngx.status = code
+ }
+}
+--- error_code: 200
+
+
+
+=== TEST 3: validate configs - empty body
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local json = require("cjson")
+ local code, body = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ ""
+ )
+
+ ngx.status = code
+ local data = json.decode(body)
+ assert(data.error_msg == "invalid request body: empty request body",
+ "expected empty body error, got: " .. data.error_msg)
+ ngx.say("passed")
+ }
+}
+--- error_code: 400
+--- response_body
+passed
+
+
+
+=== TEST 4: validate configs - invalid JSON body
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local json = require("cjson")
+ local code, body = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ "not json"
+ )
+
+ ngx.status = code
+ local data = json.decode(body)
+ assert(data.error_msg and data.error_msg:find("invalid request body",
1, true),
+ "expected 'invalid request body' error, got: " ..
tostring(data.error_msg))
+ ngx.say("passed")
+ }
+}
+--- error_code: 400
+--- response_body
+passed
+
+
+
+=== TEST 5: validate configs - invalid route (uri must be string)
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local json = require("cjson")
+ local code, body = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ [[{
+ "routes": [
+ {
+ "id": "r1",
+ "uri": 123
+ }
+ ]
+ }]]
+ )
+
+ ngx.status = code
+ local data = json.decode(body)
+ assert(data.error_msg == "Configuration validation failed",
+ "expected validation failed msg, got: " ..
tostring(data.error_msg))
+ assert(data.errors and #data.errors == 1,
+ "expected 1 error, got: " .. tostring(data.errors and
#data.errors))
+ local err = data.errors[1]
+ assert(err.resource_type == "routes", "expected resource_type=routes,
got: " .. tostring(err.resource_type))
+ assert(err.index == 0, "expected index=0, got: " ..
tostring(err.index))
+ assert(err.resource_id == "r1", "expected resource_id=r1, got: " ..
tostring(err.resource_id))
+ assert(err.error and type(err.error) == "string" and #err.error > 0,
+ "expected non-empty error string, got: " .. tostring(err.error))
+ ngx.say("passed")
+ }
+}
+--- error_code: 400
+--- response_body
+passed
+
+
+
+=== TEST 6: validate configs - duplicate route IDs
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local json = require("cjson")
+ local code, body = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ [[{
+ "routes": [
+ {
+ "id": "r1",
+ "uri": "/test1",
+ "upstream": {
+ "nodes": {"127.0.0.1:1980": 1},
+ "type": "roundrobin"
+ }
+ },
+ {
+ "id": "r1",
+ "uri": "/test2",
+ "upstream": {
+ "nodes": {"127.0.0.1:1980": 1},
+ "type": "roundrobin"
+ }
+ }
+ ]
+ }]]
+ )
+
+ ngx.status = code
+ local data = json.decode(body)
+ assert(data.error_msg == "Configuration validation failed",
+ "expected validation failed, got: " .. tostring(data.error_msg))
+ -- find the duplicate error
+ local found = false
+ for _, err in ipairs(data.errors) do
+ if err.error and err.error:find("found duplicate id r1 in routes",
1, true) then
+ found = true
+ assert(err.resource_type == "routes", "expected
resource_type=routes")
+ assert(err.resource_id == "r1", "expected resource_id=r1, got:
" .. tostring(err.resource_id))
+ assert(err.index == 1, "expected index=1 for the duplicate,
got: " .. tostring(err.index))
+ break
+ end
+ end
+ assert(found, "expected 'found duplicate id r1 in routes' error in: "
.. body)
+ ngx.say("passed")
+ }
+}
+--- error_code: 400
+--- response_body
+passed
+
+
+
+=== TEST 7: validate configs - plugin check_schema advanced validation (cors)
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local json = require("cjson")
+ local code, body = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ [[{
+ "routes": [
+ {
+ "id": "r1",
+ "uri": "/test",
+ "upstream": {
+ "nodes": {"127.0.0.1:1980": 1},
+ "type": "roundrobin"
+ },
+ "plugins": {
+ "cors": {
+ "allow_credential": true,
+ "allow_origins": "*"
+ }
+ }
+ }
+ ]
+ }]]
+ )
+
+ ngx.status = code
+ local data = json.decode(body)
+ assert(data.error_msg == "Configuration validation failed",
+ "expected validation failed, got: " .. tostring(data.error_msg))
+ assert(data.errors and #data.errors >= 1, "expected at least 1 error")
+ local err = data.errors[1]
+ assert(err.resource_type == "routes", "expected resource_type=routes")
+ assert(err.resource_id == "r1", "expected resource_id=r1, got: " ..
tostring(err.resource_id))
+ -- cors check_schema rejects allow_credential=true with
allow_origins="*"
+ assert(err.error and err.error:find("allow_credential", 1, true),
+ "expected cors allow_credential error, got: " ..
tostring(err.error))
+ ngx.say("passed")
+ }
+}
+--- error_code: 400
+--- response_body
+passed
+
+
+
+=== TEST 8: validate configs - collects all errors
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local json = require("cjson")
+ local code, body = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ [[{
+ "routes": [
+ {
+ "id": "r1",
+ "uri": 123
+ },
+ {
+ "id": "r2",
+ "uri": 456
+ }
+ ]
+ }]]
+ )
+
+ ngx.status = code
+ local data = json.decode(body)
+ assert(data.errors and #data.errors == 2,
+ "expected 2 errors, got: " .. tostring(data.errors and
#data.errors))
+ -- verify each error has correct index
+ local indices = {}
+ for _, err in ipairs(data.errors) do
+ indices[err.index] = true
+ assert(err.resource_type == "routes", "expected
resource_type=routes")
+ assert(type(err.resource_id) == "string", "expected resource_id to
be string")
+ assert(err.error and type(err.error) == "string" and #err.error >
0,
+ "expected non-empty error string, got: " ..
tostring(err.error))
+ end
+ assert(indices[0] and indices[1], "expected errors at index 0 and 1")
+ ngx.say("passed")
+ }
+}
+--- error_code: 400
+--- response_body
+passed
+
+
+
+=== TEST 9: validate configs - success with empty config (no resources)
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ [[{}]]
+ )
+
+ ngx.status = code
+ }
+}
+--- error_code: 200
+
+
+
+=== TEST 10: validate configs - does not persist changes
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+
+ -- first validate a config
+ local code = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ [[{
+ "routes": [
+ {
+ "id": "validate-test-r1",
+ "uri": "/validate-test",
+ "upstream": {
+ "nodes": {"127.0.0.1:1980": 1},
+ "type": "roundrobin"
+ }
+ }
+ ]
+ }]]
+ )
+ assert(code == 200, "validate should succeed")
+
+ -- then try to get the route - it should not exist
+ local code, body = t('/apisix/admin/routes/validate-test-r1',
ngx.HTTP_GET)
+ ngx.status = code
+ }
+}
+--- error_code: 404
+
+
+
+=== TEST 11: validate configs - invalid plugin configuration (limit-count
negative count)
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local json = require("cjson")
+ local code, body = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ [[{
+ "routes": [
+ {
+ "id": "r1",
+ "uri": "/test",
+ "upstream": {
+ "nodes": {"127.0.0.1:1980": 1},
+ "type": "roundrobin"
+ },
+ "plugins": {
+ "limit-count": {
+ "count": -1,
+ "time_window": 60,
+ "rejected_code": 503,
+ "key": "remote_addr"
+ }
+ }
+ }
+ ]
+ }]]
+ )
+
+ ngx.status = code
+ local data = json.decode(body)
+ assert(data.error_msg == "Configuration validation failed",
+ "expected validation failed, got: " .. tostring(data.error_msg))
+ assert(data.errors and #data.errors >= 1, "expected at least 1 error")
+ local err = data.errors[1]
+ assert(err.resource_type == "routes", "expected resource_type=routes")
+ assert(err.resource_id == "r1", "expected resource_id=r1, got: " ..
tostring(err.resource_id))
+ assert(err.error and err.error:find("limit%-count", 1, false),
+ "expected limit-count in error, got: " .. tostring(err.error))
+ ngx.say("passed")
+ }
+}
+--- error_code: 400
+--- response_body
+passed
+
+
+
+=== TEST 12: validate configs - invalid upstream configuration (chash missing
key)
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local json = require("cjson")
+ local code, body = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ [[{
+ "upstreams": [
+ {
+ "id": "ups-1",
+ "type": "chash",
+ "hash_on": "vars",
+ "nodes": {"127.0.0.1:1980": 1}
+ }
+ ]
+ }]]
+ )
+
+ ngx.status = code
+ local data = json.decode(body)
+ assert(data.error_msg == "Configuration validation failed",
+ "expected validation failed, got: " .. tostring(data.error_msg))
+ assert(data.errors and #data.errors >= 1, "expected at least 1 error")
+ local err = data.errors[1]
+ assert(err.resource_type == "upstreams", "expected
resource_type=upstreams, got: " .. tostring(err.resource_type))
+ assert(err.resource_id == "ups-1", "expected resource_id=ups-1, got: "
.. tostring(err.resource_id))
+ assert(err.index == 0, "expected index=0, got: " ..
tostring(err.index))
+ assert(err.error and err.error:find("missing key", 1, true),
+ "expected 'missing key' in error, got: " .. tostring(err.error))
+ ngx.say("passed")
+ }
+}
+--- error_code: 400
+--- response_body
+passed
+
+
+
+=== TEST 13: validate configs - consumer with valid plugin
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ [[{
+ "consumers": [
+ {
+ "username": "jack",
+ "plugins": {
+ "key-auth": {
+ "key": "auth-one"
+ }
+ }
+ }
+ ]
+ }]]
+ )
+
+ ngx.status = code
+ }
+}
+--- error_code: 200
+
+
+
+=== TEST 14: validate configs - routes without id (no crash on nil identifier)
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local json = require("cjson")
+ local code, _, body = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ [[{
+ "routes": [
+ {
+ "uri": "/foo",
+ "upstream": {
+ "nodes": {"127.0.0.1:1980": 1},
+ "type": "roundrobin"
+ }
+ },
+ {
+ "uri": "/bar",
+ "upstream": {
+ "nodes": {"127.0.0.1:1980": 1},
+ "type": "roundrobin"
+ }
+ }
+ ],
+ "services": [
+ {
+ "upstream": {
+ "nodes": {"127.0.0.1:1980": 1},
+ "type": "roundrobin"
+ }
+ }
+ ]
+ }]]
+ )
+
+ assert(code == 200, "expected 200, got: " .. tostring(code))
+ ngx.say("passed")
+ }
+}
+--- response_body
+passed
+
+
+
+=== TEST 15: validate configs - duplicate consumer usernames detected
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local json = require("cjson")
+ local code, body = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ [[{
+ "consumers": [
+ {
+ "username": "jack",
+ "plugins": {
+ "key-auth": {"key": "auth-one"}
+ }
+ },
+ {
+ "username": "jack",
+ "plugins": {
+ "key-auth": {"key": "auth-two"}
+ }
+ }
+ ]
+ }]]
+ )
+
+ ngx.status = code
+ local data = json.decode(body)
+ assert(data.error_msg == "Configuration validation failed",
+ "expected validation failed, got: " .. tostring(data.error_msg))
+ local found = false
+ for _, err in ipairs(data.errors) do
+ if err.error and err.error:find("found duplicate username jack in
consumers", 1, true) then
+ found = true
+ assert(err.resource_type == "consumers", "expected
resource_type=consumers")
+ assert(err.resource_id == "jack", "expected resource_id=jack,
got: " .. tostring(err.resource_id))
+ break
+ end
+ end
+ assert(found, "expected 'found duplicate username jack in consumers'
error in: " .. body)
+ ngx.say("passed")
+ }
+}
+--- error_code: 400
+--- response_body
+passed
+
+
+
+=== TEST 16: validate configs - mixed valid and invalid resources across types
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local json = require("cjson")
+ local code, body = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ [[{
+ "routes": [
+ {
+ "id": "r1",
+ "uri": "/ok",
+ "upstream": {
+ "nodes": {"127.0.0.1:1980": 1},
+ "type": "roundrobin"
+ }
+ }
+ ],
+ "upstreams": [
+ {
+ "id": "ups-bad",
+ "type": "chash",
+ "hash_on": "vars",
+ "nodes": {"127.0.0.1:1980": 1}
+ }
+ ]
+ }]]
+ )
+
+ ngx.status = code
+ local data = json.decode(body)
+ assert(data.error_msg == "Configuration validation failed",
+ "expected validation failed, got: " .. tostring(data.error_msg))
+ -- should have errors from upstreams but not routes
+ local has_upstream_err = false
+ local has_route_err = false
+ for _, err in ipairs(data.errors) do
+ if err.resource_type == "upstreams" then
+ has_upstream_err = true
+ assert(err.resource_id == "ups-bad",
+ "expected resource_id=ups-bad, got: " ..
tostring(err.resource_id))
+ elseif err.resource_type == "routes" then
+ has_route_err = true
+ end
+ end
+ assert(has_upstream_err, "expected upstream validation error, errors:
" .. body)
+ assert(not has_route_err, "route should be valid, no route errors
expected")
+ ngx.say("passed")
+ }
+}
+--- error_code: 400
+--- response_body
+passed
diff --git a/t/admin/standalone.spec.ts b/t/admin/standalone.spec.ts
index d61fb1b37..8433a1508 100644
--- a/t/admin/standalone.spec.ts
+++ b/t/admin/standalone.spec.ts
@@ -574,7 +574,7 @@ describe('Admin - Standalone', () => {
expect(resp.status).toEqual(400);
expect(resp.data).toMatchObject({
error_msg:
- 'invalid routes at index 0, err: invalid configuration: property
"uri" validation failed: wrong type: expected string, got number',
+ 'invalid configuration: property "uri" validation failed: wrong
type: expected string, got number',
});
});
@@ -599,7 +599,7 @@ describe('Admin - Standalone', () => {
expect(resp.status).toEqual(400);
expect(resp.data).toEqual({
error_msg:
- 'invalid services at index 0, err: unknown plugin [invalid-plugin]',
+ 'unknown plugin [invalid-plugin]',
});
const resp2 = await clientException.put(
ENDPOINT,
@@ -611,7 +611,7 @@ describe('Admin - Standalone', () => {
expect(resp2.status).toEqual(400);
expect(resp2.data).toEqual({
error_msg:
- 'invalid routes at index 0, err: unknown plugin [invalid-plugin]',
+ 'unknown plugin [invalid-plugin]',
});
});
@@ -626,7 +626,7 @@ describe('Admin - Standalone', () => {
expect(resp.status).toEqual(400);
expect(resp.data).toEqual({
error_msg:
- 'invalid services at index 0, err: invalid configuration: failed to
match pattern
"^((uri|server_name|server_addr|request_uri|remote_port|remote_addr|query_string|host|hostname|mqtt_client_id)|arg_[0-9a-zA-z_-]+)$"
with "args_invalid"',
+ 'invalid configuration: failed to match pattern
"^((uri|server_name|server_addr|request_uri|remote_port|remote_addr|query_string|host|hostname|mqtt_client_id)|arg_[0-9a-zA-z_-]+)$"
with "args_invalid"',
});
const resp2 = await clientException.put(
@@ -639,7 +639,7 @@ describe('Admin - Standalone', () => {
expect(resp2.status).toEqual(400);
expect(resp2.data).toEqual({
error_msg:
- 'invalid routes at index 0, err: invalid configuration: failed to
match pattern
"^((uri|server_name|server_addr|request_uri|remote_port|remote_addr|query_string|host|hostname|mqtt_client_id)|arg_[0-9a-zA-z_-]+)$"
with "args_invalid"',
+ 'invalid configuration: failed to match pattern
"^((uri|server_name|server_addr|request_uri|remote_port|remote_addr|query_string|host|hostname|mqtt_client_id)|arg_[0-9a-zA-z_-]+)$"
with "args_invalid"',
});
});
});
@@ -784,7 +784,7 @@ describe('Validate API - Standalone', () => {
errors: expect.arrayContaining([
expect.objectContaining({
resource_type: 'routes',
- error: expect.stringContaining('invalid routes at index 0'),
+ error: expect.any(String),
}),
]),
});