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),
           }),
         ]),
       });


Reply via email to