From c67bfabb7c65ec7d6526e37a51550aca763b6414 Mon Sep 17 00:00:00 2001 From: Ashish Tiwari Date: Thu, 30 Oct 2025 20:19:51 +0530 Subject: [PATCH 01/14] feat: add validate API to standalone mode --- apisix/admin/init.lua | 5 + apisix/admin/standalone.lua | 106 +++++++++++++ t/admin/standalone.spec.ts | 303 ++++++++++++++++++++++++++++++++++++ 3 files changed, 414 insertions(+) diff --git a/apisix/admin/init.lua b/apisix/admin/init.lua index 9b375b22e688..54277a5e9478 100644 --- a/apisix/admin/init.lua +++ b/apisix/admin/init.lua @@ -468,6 +468,11 @@ local standalone_uri_route = { methods = {"GET", "PUT", "HEAD"}, handler = standalone_run, }, + { + paths = [[/apisix/admin/configs/validate]], + methods = {"POST"}, + handler = standalone_run, + }, } diff --git a/apisix/admin/standalone.lua b/apisix/admin/standalone.lua index abe03de62d02..fe0ad14e57ec 100644 --- a/apisix/admin/standalone.lua +++ b/apisix/admin/standalone.lua @@ -157,6 +157,104 @@ local function check_conf(checker, schema, item, typ) }) 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 + req_body = data + + local validation_results = { + valid = true, + errors = {} + } + + + for key, conf_version_key in pairs(ALL_RESOURCE_KEYS) do + local items = req_body[key] + local resource = resources[key] or {} + + 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 + + validation_results.valid = false + table_insert(validation_results.errors, { + resource_type = key, + index = index - 1, + error = err_prefix .. err_msg + }) + end + + -- check for duplicate IDs + local duplicated, dup_err = check_duplicate(item, key, id_set) + if duplicated then + validation_results.valid = false + table_insert(validation_results.errors, { + resource_type = key, + index = index - 1, + error = dup_err + }) + end + end + end + + -- 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 + validation_results.valid = false + table_insert(validation_results.errors, { + resource_type = key, + error = conf_version_key .. " must be a number, got " .. type(new_conf_version) + }) + end + end + + + if validation_results.valid then + return core.response.exit(200, { + message = "Configuration is valid", + valid = true + }) + else + return core.response.exit(400, { + error_msg = "Configuration validation failed", + valid = false, + errors = validation_results.errors + }) + end +end + local function update(ctx) -- check digest header existence @@ -353,6 +451,14 @@ function _M.run() local method = str_lower(get_method()) if method == "put" then return update(ctx) + elseif method == "post" then + local path = ctx.var.uri + core.log.warn("PATH IS ", path) + if path == "/apisix/admin/configs/validate" or path:match("/apisix/admin/configs/validate/?$") then + return validate(ctx) + else + return core.response.exit(404, {error_msg = "Not found"}) + end elseif method == "head" then return head(ctx) else diff --git a/t/admin/standalone.spec.ts b/t/admin/standalone.spec.ts index 31addaebd49b..a0cfe0bdda99 100644 --- a/t/admin/standalone.spec.ts +++ b/t/admin/standalone.spec.ts @@ -194,6 +194,86 @@ describe('Admin - Standalone', () => { }); describe('Normal', () => { + it('validate config (success case with json)', async () => { + const resp = await client.post(`${ENDPOINT}/validate`, config1); + expect(resp.status).toEqual(200); + expect(resp.data).toEqual({ + message: 'Configuration is valid', + valid: true, + }); + }); + + it('validate config (success case with yaml)', async () => { + const resp = await client.post(`${ENDPOINT}/validate`, YAML.stringify(config1), { + headers: { 'Content-Type': 'application/yaml' }, + }); + expect(resp.status).toEqual(200); + expect(resp.data).toEqual({ + message: 'Configuration is valid', + valid: true, + }); + }); + + it('validate config (success case with multiple resources)', async () => { + const multiResourceConfig = { + routes: [ + { + id: 'r1', + uri: '/r1', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + }, + }, + { + id: 'r2', + uri: '/r2', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + }, + }, + ], + services: [ + { + id: 's1', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + }, + }, + ], + routes_conf_version: 1, + services_conf_version: 1, + }; + + const resp = await client.post(`${ENDPOINT}/validate`, multiResourceConfig); + expect(resp.status).toEqual(200); + expect(resp.data).toEqual({ + message: 'Configuration is valid', + valid: true, + }); + }); + + it('validate config with consumer credentials', async () => { + const resp = await client.post(`${ENDPOINT}/validate`, credential1); + expect(resp.status).toEqual(200); + expect(resp.data).toEqual({ + message: 'Configuration is valid', + valid: true, + }); + }); + + it('validate config does not persist changes', async () => { + // First validate a configuration + const validateResp = await client.post(`${ENDPOINT}/validate`, config1); + expect(validateResp.status).toEqual(200); + + // Then check that the configuration was not persisted + const getResp = await client.get(ENDPOINT); + expect(getResp.data.routes).toBeUndefined(); + }); + it('dump empty config (default json format)', async () => { const resp = await client.get(ENDPOINT); expect(resp.status).toEqual(200); @@ -641,5 +721,228 @@ describe('Admin - Standalone', () => { '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"', }); }); + it('validate config (duplicate route id)', async () => { + const duplicateConfig = { + routes: [ + { + id: 'r1', + uri: '/r1', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + }, + }, + { + id: 'r1', // Duplicate ID + uri: '/r2', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + }, + }, + ], + }; + + const resp = await clientException.post(`${ENDPOINT}/validate`, duplicateConfig); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + valid: false, + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'routes', + error: expect.stringContaining('found duplicate id r1 in routes'), + }), + ]), + }); + }); + + it('validate config (invalid route configuration)', async () => { + const invalidConfig = { + routes: [ + { + id: 'r1', + uri: '/r1', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + // Add an invalid field that should definitely fail validation + invalid_field: 'this_should_fail' + }, + }, + ], + }; + + const resp = await clientException.post(`${ENDPOINT}/validate`, invalidConfig); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + valid: false, + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'routes', + error: expect.stringContaining('invalid routes at index 0'), + }), + ]), + }); + }); + + it('validate config (invalid version number)', async () => { + const invalidVersionConfig = { + routes: [ + { + id: 'r1', + uri: '/r1', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + }, + }, + ], + routes_conf_version: 'not_a_number', // Invalid version type + }; + + const resp = await clientException.post(`${ENDPOINT}/validate`, invalidVersionConfig); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + valid: false, + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'routes', + error: expect.stringContaining('routes_conf_version must be a number'), + }), + ]), + }); + }); + + it('validate config (empty body)', async () => { + const resp = await clientException.post(`${ENDPOINT}/validate`, ''); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'invalid request body: empty request body', + }); + }); + + it('validate config (invalid YAML)', async () => { + const resp = await clientException.post(`${ENDPOINT}/validate`, 'invalid: yaml: [', { + headers: { 'Content-Type': 'application/yaml' }, + }); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: expect.stringContaining('invalid request body:'), + }); + }); + + it('validate config (duplicate consumer username)', async () => { + const duplicateConsumerConfig = { + consumers: [ + { + username: 'consumer1', + plugins: { + 'key-auth': { + key: 'consumer1', + }, + }, + }, + { + username: 'consumer1', // Duplicate username + plugins: { + 'key-auth': { + key: 'consumer1', + }, + }, + }, + ], + }; + + const resp = await clientException.post(`${ENDPOINT}/validate`, duplicateConsumerConfig); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + valid: false, + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'consumers', + error: expect.stringContaining('found duplicate username consumer1 in consumers'), + }), + ]), + }); + }); + + it('validate config (duplicate consumer credential id)', async () => { + const duplicateCredentialConfig = { + consumers: [ + { + username: 'john_1', + }, + { + id: 'john_1/credentials/john-a', + plugins: { + 'key-auth': { + key: 'auth-a', + }, + }, + }, + { + id: 'john_1/credentials/john-a', // Duplicate credential ID + plugins: { + 'key-auth': { + key: 'auth-a', + }, + }, + }, + ], + }; + + const resp = await clientException.post(`${ENDPOINT}/validate`, duplicateCredentialConfig); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + valid: false, + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'consumers', + error: expect.stringContaining('found duplicate credential id john_1/credentials/john-a in consumers'), + }), + ]), + }); + }); + + it('validate config (invalid plugin)', async () => { + const resp = await clientException.post( + `${ENDPOINT}/validate`, + routeWithUnknownPlugins, + ); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + valid: false, + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'routes', + error: expect.stringContaining('unknown plugin [invalid-plugin]'), + }), + ]), + }); + }); + + it('validate config (invalid upstream)', async () => { + const resp = await clientException.post( + `${ENDPOINT}/validate`, + routeWithInvalidUpstream, + ); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + valid: false, + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'routes', + error: expect.stringContaining('failed to match pattern'), + }), + ]), + }); + }); }); }); From 25bba3cd3120fcaf5f9e46e531e43615960bd1b5 Mon Sep 17 00:00:00 2001 From: Ashish Tiwari Date: Thu, 30 Oct 2025 20:22:08 +0530 Subject: [PATCH 02/14] f --- apisix/admin/standalone.lua | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apisix/admin/standalone.lua b/apisix/admin/standalone.lua index fe0ad14e57ec..62dd7792f1d2 100644 --- a/apisix/admin/standalone.lua +++ b/apisix/admin/standalone.lua @@ -453,8 +453,7 @@ function _M.run() return update(ctx) elseif method == "post" then local path = ctx.var.uri - core.log.warn("PATH IS ", path) - if path == "/apisix/admin/configs/validate" or path:match("/apisix/admin/configs/validate/?$") then + if path == "/apisix/admin/configs/validate" then return validate(ctx) else return core.response.exit(404, {error_msg = "Not found"}) From da12d556a8bf1407de449b3772f679b910db249b Mon Sep 17 00:00:00 2001 From: Ashish Tiwari Date: Thu, 30 Oct 2025 20:35:39 +0530 Subject: [PATCH 03/14] fix lint --- apisix/admin/standalone.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/apisix/admin/standalone.lua b/apisix/admin/standalone.lua index 62dd7792f1d2..977c8e90c0a1 100644 --- a/apisix/admin/standalone.lua +++ b/apisix/admin/standalone.lua @@ -22,6 +22,7 @@ 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"] From 5d214db1ca7cb720e7599cf2b510c5d34d7dfc31 Mon Sep 17 00:00:00 2001 From: Ashish Tiwari Date: Fri, 31 Oct 2025 12:53:19 +0530 Subject: [PATCH 04/14] apply suggestion --- apisix/admin/standalone.lua | 77 +++++++++++++++++++------------------ 1 file changed, 40 insertions(+), 37 deletions(-) diff --git a/apisix/admin/standalone.lua b/apisix/admin/standalone.lua index 977c8e90c0a1..5e06b19c0a5e 100644 --- a/apisix/admin/standalone.lua +++ b/apisix/admin/standalone.lua @@ -1,4 +1,3 @@ --- -- 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. @@ -158,41 +157,13 @@ local function check_conf(checker, schema, item, typ) }) 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 - req_body = data +local function validate_configuration(req_body) local validation_results = { valid = true, errors = {} } - for key, conf_version_key in pairs(ALL_RESOURCE_KEYS) do local items = req_body[key] local resource = resources[key] or {} @@ -241,6 +212,38 @@ local function validate(ctx) end end + return validation_results +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 validation_results = validate_configuration(data) if validation_results.valid then return core.response.exit(200, { @@ -256,7 +259,6 @@ local function validate(ctx) end end - local function update(ctx) -- check digest header existence local digest = core.request.header(nil, METADATA_DIGEST) @@ -318,9 +320,8 @@ local function update(ctx) local items = req_body[key] local new_conf_version = req_body[conf_version_key] local resource = resources[key] or {} - if not new_conf_version then - new_conf_version = conf_version + 1 - else + + if new_conf_version then if type(new_conf_version) ~= "number" then return core.response.exit(400, { error_msg = conf_version_key .. " must be a number", @@ -332,9 +333,10 @@ local function update(ctx) " must be greater than or equal to (" .. conf_version .. ")", }) end + else + new_conf_version = conf_version + 1 end - apisix_yaml[conf_version_key] = new_conf_version if new_conf_version == conf_version then apisix_yaml[key] = config and config[key] @@ -350,14 +352,15 @@ local function update(ctx) 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 - core.response.exit(400, { error_msg = err_prefix .. err_msg }) + return core.response.exit(400, { error_msg = err_prefix .. err_msg }) end + -- prevent updating resource with the same ID -- (e.g., service ID or other resource IDs) in a single request local duplicated, err = check_duplicate(item, key, id_set) if duplicated then core.log.error(err) - core.response.exit(400, { error_msg = err }) + return core.response.exit(400, { error_msg = err }) end table_insert(apisix_yaml[key], item) From 188fa064c06828f9e8c808a4534fbfc1c8e6b271 Mon Sep 17 00:00:00 2001 From: Ashish Tiwari Date: Fri, 31 Oct 2025 13:19:57 +0530 Subject: [PATCH 05/14] f --- apisix/admin/standalone.lua | 90 ++++++++++++++++++++----------------- 1 file changed, 48 insertions(+), 42 deletions(-) diff --git a/apisix/admin/standalone.lua b/apisix/admin/standalone.lua index 5e06b19c0a5e..dd12e0213cfe 100644 --- a/apisix/admin/standalone.lua +++ b/apisix/admin/standalone.lua @@ -158,7 +158,7 @@ local function check_conf(checker, schema, item, typ) end -local function validate_configuration(req_body) +local function validate_configuration(req_body, collect_all_errors) local validation_results = { valid = true, errors = {} @@ -168,6 +168,26 @@ local function validate_configuration(req_body) 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 + local error_msg + if collect_all_errors then + error_msg = conf_version_key .. " must be a number, got " .. type(new_conf_version) + else + error_msg = conf_version_key .. " must be a number" + end + + if not collect_all_errors then + return false, error_msg + end + validation_results.valid = false + table_insert(validation_results.errors, { + resource_type = key, + error = error_msg + }) + end + if items and #items > 0 then local item_schema = resource.schema local item_checker = resource.checker @@ -179,18 +199,25 @@ local function validate_configuration(req_body) 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 validation_results.valid = false table_insert(validation_results.errors, { resource_type = key, index = index - 1, - error = err_prefix .. err_msg + 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 validation_results.valid = false table_insert(validation_results.errors, { resource_type = key, @@ -200,19 +227,13 @@ local function validate_configuration(req_body) end end end - - -- 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 - validation_results.valid = false - table_insert(validation_results.errors, { - resource_type = key, - error = conf_version_key .. " must be a number, got " .. type(new_conf_version) - }) - end end - return validation_results + if collect_all_errors then + return validation_results.valid, validation_results + else + return validation_results.valid, nil + end end local function validate(ctx) @@ -243,9 +264,9 @@ local function validate(ctx) return core.response.exit(400, {error_msg = "invalid request body: " .. err}) end - local validation_results = validate_configuration(data) + local valid, validation_results = validate_configuration(data, true) -- collect_all_errors = true - if validation_results.valid then + if valid then return core.response.exit(200, { message = "Configuration is valid", valid = true @@ -312,21 +333,21 @@ local function update(ctx) return core.response.exit(204) end - -- check input by jsonschema + -- Use shared validation function (collect_all_errors = false for immediate return) + local valid, error_msg = validate_configuration(req_body, false) + if not valid then + return core.response.exit(400, { error_msg = error_msg }) + end + + -- check input by jsonschema and build the final config local apisix_yaml = {} for key, conf_version_key in pairs(ALL_RESOURCE_KEYS) do local conf_version = config and config[conf_version_key] or 0 local items = req_body[key] local new_conf_version = req_body[conf_version_key] - local resource = resources[key] or {} if new_conf_version then - if type(new_conf_version) ~= "number" then - return core.response.exit(400, { - error_msg = conf_version_key .. " must be a number", - }) - end if new_conf_version < conf_version then return core.response.exit(400, { error_msg = conf_version_key .. @@ -342,19 +363,9 @@ local function update(ctx) apisix_yaml[key] = config and config[key] elseif items and #items > 0 then apisix_yaml[key] = table_new(#items, 0) - 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 - return core.response.exit(400, { error_msg = err_prefix .. err_msg }) - end - + for _, item in ipairs(items) do -- prevent updating resource with the same ID -- (e.g., service ID or other resource IDs) in a single request local duplicated, err = check_duplicate(item, key, id_set) @@ -382,7 +393,6 @@ local function update(ctx) return core.response.exit(202) end - local function get(ctx) local accept = core.request.header(nil, "accept") or "application/json" local want_yaml_resp = core.string.has_prefix(accept, "application/yaml") @@ -390,9 +400,9 @@ local function get(ctx) local config, err = get_config() if not config then if err ~= NOT_FOUND_ERR then - core.log.error("failed to get config from shared dict: ", err) + core.log.error("failed to get config from shared_dict: ", err) return core.response.exit(500, { - error_msg = "failed to get config from shared dict: " .. err + error_msg = "failed to get config from shared_dict: " .. err }) end config = {} @@ -432,14 +442,13 @@ local function get(ctx) return core.response.exit(200, resp) end - local function head(ctx) local config, err = get_config() if not config then if err ~= NOT_FOUND_ERR then - core.log.error("failed to get config from shared dict: ", err) + core.log.error("failed to get config from shared_dict: ", err) return core.response.exit(500, { - error_msg = "failed to get config from shared dict: " .. err + error_msg = "failed to get config from shared_dict: " .. err }) end end @@ -449,7 +458,6 @@ local function head(ctx) return core.response.exit(200) end - function _M.run() local ctx = ngx.ctx.api_ctx local method = str_lower(get_method()) @@ -468,8 +476,6 @@ function _M.run() return get(ctx) end end - - local patch_schema do local resource_schema = { From 2789a0fce0a7f9385856d1bac016a9049c20ffee Mon Sep 17 00:00:00 2001 From: Ashish Tiwari Date: Fri, 31 Oct 2025 13:28:41 +0530 Subject: [PATCH 06/14] f --- apisix/admin/standalone.lua | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apisix/admin/standalone.lua b/apisix/admin/standalone.lua index dd12e0213cfe..ae1d84e8cac5 100644 --- a/apisix/admin/standalone.lua +++ b/apisix/admin/standalone.lua @@ -264,7 +264,7 @@ local function validate(ctx) return core.response.exit(400, {error_msg = "invalid request body: " .. err}) end - local valid, validation_results = validate_configuration(data, true) -- collect_all_errors = true + local valid, validation_results = validate_configuration(data, true) if valid then return core.response.exit(200, { @@ -333,7 +333,6 @@ local function update(ctx) return core.response.exit(204) end - -- Use shared validation function (collect_all_errors = false for immediate return) local valid, error_msg = validate_configuration(req_body, false) if not valid then return core.response.exit(400, { error_msg = error_msg }) From e0d83b2466d408d21a93f71f2c729fcf63e890d1 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Wed, 19 Nov 2025 08:15:08 +0800 Subject: [PATCH 07/14] remove redundant code --- apisix/admin/standalone.lua | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/apisix/admin/standalone.lua b/apisix/admin/standalone.lua index ae1d84e8cac5..0494c9657d07 100644 --- a/apisix/admin/standalone.lua +++ b/apisix/admin/standalone.lua @@ -361,20 +361,7 @@ local function update(ctx) if new_conf_version == conf_version then apisix_yaml[key] = config and config[key] elseif items and #items > 0 then - apisix_yaml[key] = table_new(#items, 0) - local id_set = {} - - for _, item in ipairs(items) do - -- prevent updating resource with the same ID - -- (e.g., service ID or other resource IDs) in a single request - local duplicated, err = check_duplicate(item, key, id_set) - if duplicated then - core.log.error(err) - return core.response.exit(400, { error_msg = err }) - end - - table_insert(apisix_yaml[key], item) - end + apisix_yaml[key] = items end end @@ -462,18 +449,22 @@ function _M.run() local method = str_lower(get_method()) if method == "put" then return update(ctx) - elseif method == "post" then + end + + if method == "post" then local path = ctx.var.uri if path == "/apisix/admin/configs/validate" then return validate(ctx) else return core.response.exit(404, {error_msg = "Not found"}) end - elseif method == "head" then + end + + if method == "head" then return head(ctx) - else - return get(ctx) end + + return get(ctx) end local patch_schema do From 9d32ac1239db3dffd2348bf6298c8c9ece1be882 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Wed, 19 Nov 2025 08:17:28 +0800 Subject: [PATCH 08/14] fix lint --- apisix/admin/standalone.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apisix/admin/standalone.lua b/apisix/admin/standalone.lua index 0494c9657d07..294be475de3f 100644 --- a/apisix/admin/standalone.lua +++ b/apisix/admin/standalone.lua @@ -459,7 +459,7 @@ function _M.run() return core.response.exit(404, {error_msg = "Not found"}) end end - + if method == "head" then return head(ctx) end From b0be53b327045ff8ca2cea84bb9948effcbed8c1 Mon Sep 17 00:00:00 2001 From: rongxin Date: Wed, 19 Nov 2025 09:15:46 +0800 Subject: [PATCH 09/14] fix lint2 --- apisix/admin/standalone.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/apisix/admin/standalone.lua b/apisix/admin/standalone.lua index 294be475de3f..f55a90ac0b95 100644 --- a/apisix/admin/standalone.lua +++ b/apisix/admin/standalone.lua @@ -28,7 +28,6 @@ local shared_dict = ngx.shared["standalone-config"] local timer_every = ngx.timer.every local exiting = ngx.worker.exiting local table_insert = table.insert -local table_new = require("table.new") local yaml = require("lyaml") local events = require("apisix.events") local core = require("apisix.core") From 84631ce5103e192ec6980fd32ca989fb45a1cdd5 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Thu, 20 Nov 2025 10:33:33 +0800 Subject: [PATCH 10/14] update conmments --- apisix/admin/standalone.lua | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/apisix/admin/standalone.lua b/apisix/admin/standalone.lua index f55a90ac0b95..2787d79da9dc 100644 --- a/apisix/admin/standalone.lua +++ b/apisix/admin/standalone.lua @@ -170,20 +170,13 @@ local function validate_configuration(req_body, collect_all_errors) -- 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 - local error_msg - if collect_all_errors then - error_msg = conf_version_key .. " must be a number, got " .. type(new_conf_version) - else - error_msg = conf_version_key .. " must be a number" - end - if not collect_all_errors then - return false, error_msg + return false, config_version_key .. " must be a number" end validation_results.valid = false table_insert(validation_results.errors, { resource_type = key, - error = error_msg + error = conf_version_key .. " must be a number, got " .. type(new_conf_version) }) end @@ -264,19 +257,18 @@ local function validate(ctx) end local valid, validation_results = validate_configuration(data, true) - - if valid then - return core.response.exit(200, { - message = "Configuration is valid", - valid = true - }) - else + if not valid then return core.response.exit(400, { error_msg = "Configuration validation failed", valid = false, errors = validation_results.errors }) end + + return core.response.exit(200, { + message = "Configuration is valid", + valid = true + }) end local function update(ctx) @@ -316,8 +308,7 @@ local function update(ctx) req_body = data local config, err = get_config() - if not config then - if err ~= NOT_FOUND_ERR then + if err and err ~= NOT_FOUND_ERR then core.log.error("failed to get config from shared dict: ", err) return core.response.exit(500, { error_msg = "failed to get config from shared dict: " .. err From 7355892dbb59284050b6569153d6ddb5d1607395 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Thu, 20 Nov 2025 11:57:39 +0800 Subject: [PATCH 11/14] fix test --- apisix/admin/standalone.lua | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apisix/admin/standalone.lua b/apisix/admin/standalone.lua index 2787d79da9dc..472e24ef8cc2 100644 --- a/apisix/admin/standalone.lua +++ b/apisix/admin/standalone.lua @@ -171,7 +171,7 @@ local function validate_configuration(req_body, collect_all_errors) 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, config_version_key .. " must be a number" + return false, conf_version_key .. " must be a number" end validation_results.valid = false table_insert(validation_results.errors, { @@ -309,11 +309,10 @@ local function update(ctx) local config, err = get_config() if err and err ~= NOT_FOUND_ERR then - core.log.error("failed to get config from shared dict: ", err) - return core.response.exit(500, { - error_msg = "failed to get config from shared dict: " .. err - }) - end + core.log.error("failed to get config from shared dict: ", err) + return core.response.exit(500, { + error_msg = "failed to get config from shared dict: " .. err + }) end -- if the client passes in the same digest, the configuration is not updated From 64e8db25184707b10e24e3b6c15a121dfee3f263 Mon Sep 17 00:00:00 2001 From: rongxin Date: Mon, 24 Nov 2025 10:06:10 +0800 Subject: [PATCH 12/14] update message and test --- apisix/admin/standalone.lua | 6 +- t/admin/standalone.spec.ts | 472 +++++++++++++++++------------------- 2 files changed, 229 insertions(+), 249 deletions(-) diff --git a/apisix/admin/standalone.lua b/apisix/admin/standalone.lua index 472e24ef8cc2..7ff121565c24 100644 --- a/apisix/admin/standalone.lua +++ b/apisix/admin/standalone.lua @@ -260,15 +260,11 @@ local function validate(ctx) if not valid then return core.response.exit(400, { error_msg = "Configuration validation failed", - valid = false, errors = validation_results.errors }) end - return core.response.exit(200, { - message = "Configuration is valid", - valid = true - }) + return core.response.exit(200) end local function update(ctx) diff --git a/t/admin/standalone.spec.ts b/t/admin/standalone.spec.ts index a0cfe0bdda99..5924dc4c29c6 100644 --- a/t/admin/standalone.spec.ts +++ b/t/admin/standalone.spec.ts @@ -18,6 +18,7 @@ import axios from 'axios'; import YAML from 'yaml'; const ENDPOINT = '/apisix/admin/configs'; +const VALIDATE_ENDPOINT = '/apisix/admin/configs/validate'; const HEADER_LAST_MODIFIED = 'x-last-modified'; const HEADER_DIGEST = 'x-digest'; const clientConfig = { @@ -194,86 +195,6 @@ describe('Admin - Standalone', () => { }); describe('Normal', () => { - it('validate config (success case with json)', async () => { - const resp = await client.post(`${ENDPOINT}/validate`, config1); - expect(resp.status).toEqual(200); - expect(resp.data).toEqual({ - message: 'Configuration is valid', - valid: true, - }); - }); - - it('validate config (success case with yaml)', async () => { - const resp = await client.post(`${ENDPOINT}/validate`, YAML.stringify(config1), { - headers: { 'Content-Type': 'application/yaml' }, - }); - expect(resp.status).toEqual(200); - expect(resp.data).toEqual({ - message: 'Configuration is valid', - valid: true, - }); - }); - - it('validate config (success case with multiple resources)', async () => { - const multiResourceConfig = { - routes: [ - { - id: 'r1', - uri: '/r1', - upstream: { - nodes: { '127.0.0.1:1980': 1 }, - type: 'roundrobin', - }, - }, - { - id: 'r2', - uri: '/r2', - upstream: { - nodes: { '127.0.0.1:1980': 1 }, - type: 'roundrobin', - }, - }, - ], - services: [ - { - id: 's1', - upstream: { - nodes: { '127.0.0.1:1980': 1 }, - type: 'roundrobin', - }, - }, - ], - routes_conf_version: 1, - services_conf_version: 1, - }; - - const resp = await client.post(`${ENDPOINT}/validate`, multiResourceConfig); - expect(resp.status).toEqual(200); - expect(resp.data).toEqual({ - message: 'Configuration is valid', - valid: true, - }); - }); - - it('validate config with consumer credentials', async () => { - const resp = await client.post(`${ENDPOINT}/validate`, credential1); - expect(resp.status).toEqual(200); - expect(resp.data).toEqual({ - message: 'Configuration is valid', - valid: true, - }); - }); - - it('validate config does not persist changes', async () => { - // First validate a configuration - const validateResp = await client.post(`${ENDPOINT}/validate`, config1); - expect(validateResp.status).toEqual(200); - - // Then check that the configuration was not persisted - const getResp = await client.get(ENDPOINT); - expect(getResp.data.routes).toBeUndefined(); - }); - it('dump empty config (default json format)', async () => { const resp = await client.get(ENDPOINT); expect(resp.status).toEqual(200); @@ -721,8 +642,25 @@ describe('Admin - Standalone', () => { '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"', }); }); - it('validate config (duplicate route id)', async () => { - const duplicateConfig = { + }); +}); + +describe('Validate API - Standalone', () => { + describe('Normal', () => { + it('validate config (success case with json)', async () => { + const resp = await client.post(VALIDATE_ENDPOINT, config1); + expect(resp.status).toEqual(200); + }); + + it('validate config (success case with yaml)', async () => { + const resp = await client.post(VALIDATE_ENDPOINT, YAML.stringify(config1), { + headers: { 'Content-Type': 'application/yaml' }, + }); + expect(resp.status).toEqual(200); + }); + + it('validate config (success case with multiple resources)', async () => { + const multiResourceConfig = { routes: [ { id: 'r1', @@ -733,7 +671,7 @@ describe('Admin - Standalone', () => { }, }, { - id: 'r1', // Duplicate ID + id: 'r2', uri: '/r2', upstream: { nodes: { '127.0.0.1:1980': 1 }, @@ -741,54 +679,41 @@ describe('Admin - Standalone', () => { }, }, ], - }; - - const resp = await clientException.post(`${ENDPOINT}/validate`, duplicateConfig); - expect(resp.status).toEqual(400); - expect(resp.data).toEqual({ - error_msg: 'Configuration validation failed', - valid: false, - errors: expect.arrayContaining([ - expect.objectContaining({ - resource_type: 'routes', - error: expect.stringContaining('found duplicate id r1 in routes'), - }), - ]), - }); - }); - - it('validate config (invalid route configuration)', async () => { - const invalidConfig = { - routes: [ + services: [ { - id: 'r1', - uri: '/r1', + id: 's1', upstream: { nodes: { '127.0.0.1:1980': 1 }, type: 'roundrobin', - // Add an invalid field that should definitely fail validation - invalid_field: 'this_should_fail' }, }, ], + routes_conf_version: 1, + services_conf_version: 1, }; - const resp = await clientException.post(`${ENDPOINT}/validate`, invalidConfig); - expect(resp.status).toEqual(400); - expect(resp.data).toEqual({ - error_msg: 'Configuration validation failed', - valid: false, - errors: expect.arrayContaining([ - expect.objectContaining({ - resource_type: 'routes', - error: expect.stringContaining('invalid routes at index 0'), - }), - ]), - }); + const resp = await client.post(VALIDATE_ENDPOINT, multiResourceConfig); + expect(resp.status).toEqual(200); }); - it('validate config (invalid version number)', async () => { - const invalidVersionConfig = { + it('validate config with consumer credentials', async () => { + const resp = await client.post(VALIDATE_ENDPOINT, credential1); + expect(resp.status).toEqual(200); + }); + + it('validate config does not persist changes', async () => { + // First validate a configuration + const validateResp = await client.post(VALIDATE_ENDPOINT, config1); + expect(validateResp.status).toEqual(200); + + // Then check that the configuration was not persisted + const getResp = await client.get(ENDPOINT); + expect(getResp.data.routes).toBeUndefined(); + }); + }); + describe('Exceptions', () => { + it('validate config (duplicate route id)', async () => { + const duplicateConfig = { routes: [ { id: 'r1', @@ -798,151 +723,210 @@ describe('Admin - Standalone', () => { type: 'roundrobin', }, }, - ], - routes_conf_version: 'not_a_number', // Invalid version type - }; + }, + { + id: 'r1', // Duplicate ID + uri: '/r2', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + }, + }, + ], + }; + + const resp = await clientException.post(VALIDATE_ENDPOINT, duplicateConfig); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'routes', + error: expect.stringContaining('found duplicate id r1 in routes'), + }), + ]), + }); + }); - const resp = await clientException.post(`${ENDPOINT}/validate`, invalidVersionConfig); - expect(resp.status).toEqual(400); - expect(resp.data).toEqual({ - error_msg: 'Configuration validation failed', - valid: false, - errors: expect.arrayContaining([ - expect.objectContaining({ - resource_type: 'routes', - error: expect.stringContaining('routes_conf_version must be a number'), - }), - ]), - }); + it('validate config (invalid route configuration)', async () => { + const invalidConfig = { + routes: [ + { + id: 'r1', + uri: '/r1', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + // Add an invalid field that should definitely fail validation + invalid_field: 'this_should_fail' + }, + }, + ], + }; + + const resp = await clientException.post(VALIDATE_ENDPOINT, invalidConfig); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'routes', + error: expect.stringContaining('invalid routes at index 0'), + }), + ]), }); + }); - it('validate config (empty body)', async () => { - const resp = await clientException.post(`${ENDPOINT}/validate`, ''); - expect(resp.status).toEqual(400); - expect(resp.data).toEqual({ - error_msg: 'invalid request body: empty request body', - }); + it('validate config (invalid version number)', async () => { + const invalidVersionConfig = { + routes: [ + { + id: 'r1', + uri: '/r1', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + }, + }, + ], + routes_conf_version: 'not_a_number', // Invalid version type + }; + + const resp = await clientException.post(VALIDATE_ENDPOINT, invalidVersionConfig); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'routes', + error: expect.stringContaining('routes_conf_version must be a number'), + }), + ]), }); + }); - it('validate config (invalid YAML)', async () => { - const resp = await clientException.post(`${ENDPOINT}/validate`, 'invalid: yaml: [', { - headers: { 'Content-Type': 'application/yaml' }, - }); - expect(resp.status).toEqual(400); - expect(resp.data).toEqual({ - error_msg: expect.stringContaining('invalid request body:'), - }); + it('validate config (empty body)', async () => { + const resp = await clientException.post(VALIDATE_ENDPOINT, ''); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'invalid request body: empty request body', }); + }); - it('validate config (duplicate consumer username)', async () => { - const duplicateConsumerConfig = { - consumers: [ - { - username: 'consumer1', - plugins: { - 'key-auth': { - key: 'consumer1', - }, + it('validate config (invalid YAML)', async () => { + const resp = await clientException.post(VALIDATE_ENDPOINT, 'invalid: yaml: [', { + headers: { 'Content-Type': 'application/yaml' }, + }); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: expect.stringContaining('invalid request body:'), + }); + }); + + it('validate config (duplicate consumer username)', async () => { + const duplicateConsumerConfig = { + consumers: [ + { + username: 'consumer1', + plugins: { + 'key-auth': { + key: 'consumer1', }, }, - { - username: 'consumer1', // Duplicate username - plugins: { - 'key-auth': { - key: 'consumer1', - }, + }, + { + username: 'consumer1', // Duplicate username + plugins: { + 'key-auth': { + key: 'consumer1', }, }, - ], - }; - - const resp = await clientException.post(`${ENDPOINT}/validate`, duplicateConsumerConfig); - expect(resp.status).toEqual(400); - expect(resp.data).toEqual({ - error_msg: 'Configuration validation failed', - valid: false, - errors: expect.arrayContaining([ - expect.objectContaining({ - resource_type: 'consumers', - error: expect.stringContaining('found duplicate username consumer1 in consumers'), - }), - ]), - }); + }, + ], + }; + + const resp = await clientException.post(VALIDATE_ENDPOINT, duplicateConsumerConfig); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'consumers', + error: expect.stringContaining('found duplicate username consumer1 in consumers'), + }), + ]), }); + }); - it('validate config (duplicate consumer credential id)', async () => { - const duplicateCredentialConfig = { - consumers: [ - { - username: 'john_1', - }, - { - id: 'john_1/credentials/john-a', - plugins: { - 'key-auth': { - key: 'auth-a', - }, + it('validate config (duplicate consumer credential id)', async () => { + const duplicateCredentialConfig = { + consumers: [ + { + username: 'john_1', + }, + { + id: 'john_1/credentials/john-a', + plugins: { + 'key-auth': { + key: 'auth-a', }, }, - { - id: 'john_1/credentials/john-a', // Duplicate credential ID - plugins: { - 'key-auth': { - key: 'auth-a', - }, + }, + { + id: 'john_1/credentials/john-a', // Duplicate credential ID + plugins: { + 'key-auth': { + key: 'auth-a', }, }, - ], - }; - - const resp = await clientException.post(`${ENDPOINT}/validate`, duplicateCredentialConfig); - expect(resp.status).toEqual(400); - expect(resp.data).toEqual({ - error_msg: 'Configuration validation failed', - valid: false, - errors: expect.arrayContaining([ - expect.objectContaining({ - resource_type: 'consumers', - error: expect.stringContaining('found duplicate credential id john_1/credentials/john-a in consumers'), - }), - ]), - }); + }, + ], + }; + + const resp = await clientException.post(VALIDATE_ENDPOINT, duplicateCredentialConfig); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'consumers', + error: expect.stringContaining('found duplicate credential id john_1/credentials/john-a in consumers'), + }), + ]), }); + }); - it('validate config (invalid plugin)', async () => { - const resp = await clientException.post( - `${ENDPOINT}/validate`, - routeWithUnknownPlugins, - ); - expect(resp.status).toEqual(400); - expect(resp.data).toEqual({ - error_msg: 'Configuration validation failed', - valid: false, - errors: expect.arrayContaining([ - expect.objectContaining({ - resource_type: 'routes', - error: expect.stringContaining('unknown plugin [invalid-plugin]'), - }), - ]), - }); + it('validate config (invalid plugin)', async () => { + const resp = await clientException.post( + VALIDATE_ENDPOINT, + routeWithUnknownPlugins, + ); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'routes', + error: expect.stringContaining('unknown plugin [invalid-plugin]'), + }), + ]), }); + }); - it('validate config (invalid upstream)', async () => { - const resp = await clientException.post( - `${ENDPOINT}/validate`, - routeWithInvalidUpstream, - ); - expect(resp.status).toEqual(400); - expect(resp.data).toEqual({ - error_msg: 'Configuration validation failed', - valid: false, - errors: expect.arrayContaining([ - expect.objectContaining({ - resource_type: 'routes', - error: expect.stringContaining('failed to match pattern'), - }), - ]), - }); + it('validate config (invalid upstream)', async () => { + const resp = await clientException.post( + VALIDATE_ENDPOINT, + routeWithInvalidUpstream, + ); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'routes', + error: expect.stringContaining('failed to match pattern'), + }), + ]), }); }); }); From ee598b3cac0317aa2343e5df6437a0c35389b84f Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Mon, 24 Nov 2025 04:09:50 +0000 Subject: [PATCH 13/14] update test --- t/admin/standalone.spec.ts | 370 +++++++++++++++++++------------------ 1 file changed, 192 insertions(+), 178 deletions(-) diff --git a/t/admin/standalone.spec.ts b/t/admin/standalone.spec.ts index 5924dc4c29c6..d61fb1b37788 100644 --- a/t/admin/standalone.spec.ts +++ b/t/admin/standalone.spec.ts @@ -646,6 +646,17 @@ describe('Admin - Standalone', () => { }); describe('Validate API - Standalone', () => { + const client = axios.create(clientConfig); + client.interceptors.response.use((response) => { + const contentType = response.headers['content-type'] || ''; + if ( + contentType.includes('application/yaml') && + typeof response.data === 'string' && + response.config.responseType !== 'text' + ) + response.data = YAML.parse(response.data); + return response; + }); describe('Normal', () => { it('validate config (success case with json)', async () => { const resp = await client.post(VALIDATE_ENDPOINT, config1); @@ -712,6 +723,10 @@ describe('Validate API - Standalone', () => { }); }); describe('Exceptions', () => { + const clientException = axios.create({ + ...clientConfig, + validateStatus: () => true, + }); it('validate config (duplicate route id)', async () => { const duplicateConfig = { routes: [ @@ -723,210 +738,209 @@ describe('Validate API - Standalone', () => { type: 'roundrobin', }, }, - }, - { - id: 'r1', // Duplicate ID - uri: '/r2', - upstream: { - nodes: { '127.0.0.1:1980': 1 }, - type: 'roundrobin', - }, - }, - ], - }; - - const resp = await clientException.post(VALIDATE_ENDPOINT, duplicateConfig); - expect(resp.status).toEqual(400); - expect(resp.data).toEqual({ - error_msg: 'Configuration validation failed', - errors: expect.arrayContaining([ - expect.objectContaining({ - resource_type: 'routes', - error: expect.stringContaining('found duplicate id r1 in routes'), - }), - ]), + { + id: 'r1', // Duplicate ID + uri: '/r2', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + }, + },], + }; + + const resp = await clientException.post(VALIDATE_ENDPOINT, duplicateConfig); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'routes', + error: expect.stringContaining('found duplicate id r1 in routes'), + }), + ]), + }); }); - }); - it('validate config (invalid route configuration)', async () => { - const invalidConfig = { - routes: [ - { - id: 'r1', - uri: '/r1', - upstream: { - nodes: { '127.0.0.1:1980': 1 }, - type: 'roundrobin', - // Add an invalid field that should definitely fail validation - invalid_field: 'this_should_fail' + it('validate config (invalid route configuration)', async () => { + const invalidConfig = { + routes: [ + { + id: 'r1', + uri: '/r1', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + // Add an invalid field that should definitely fail validation + invalid_field: 'this_should_fail' + }, }, - }, - ], - }; - - const resp = await clientException.post(VALIDATE_ENDPOINT, invalidConfig); - expect(resp.status).toEqual(400); - expect(resp.data).toEqual({ - error_msg: 'Configuration validation failed', - errors: expect.arrayContaining([ - expect.objectContaining({ - resource_type: 'routes', - error: expect.stringContaining('invalid routes at index 0'), - }), - ]), + ], + }; + + const resp = await clientException.post(VALIDATE_ENDPOINT, invalidConfig); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'routes', + error: expect.stringContaining('invalid routes at index 0'), + }), + ]), + }); }); - }); - it('validate config (invalid version number)', async () => { - const invalidVersionConfig = { - routes: [ - { - id: 'r1', - uri: '/r1', - upstream: { - nodes: { '127.0.0.1:1980': 1 }, - type: 'roundrobin', + it('validate config (invalid version number)', async () => { + const invalidVersionConfig = { + routes: [ + { + id: 'r1', + uri: '/r1', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + }, }, - }, - ], - routes_conf_version: 'not_a_number', // Invalid version type - }; - - const resp = await clientException.post(VALIDATE_ENDPOINT, invalidVersionConfig); - expect(resp.status).toEqual(400); - expect(resp.data).toEqual({ - error_msg: 'Configuration validation failed', - errors: expect.arrayContaining([ - expect.objectContaining({ - resource_type: 'routes', - error: expect.stringContaining('routes_conf_version must be a number'), - }), - ]), - }); - }); + ], + routes_conf_version: 'not_a_number', // Invalid version type + }; - it('validate config (empty body)', async () => { - const resp = await clientException.post(VALIDATE_ENDPOINT, ''); - expect(resp.status).toEqual(400); - expect(resp.data).toEqual({ - error_msg: 'invalid request body: empty request body', + const resp = await clientException.post(VALIDATE_ENDPOINT, invalidVersionConfig); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'routes', + error: expect.stringContaining('routes_conf_version must be a number'), + }), + ]), + }); }); - }); - it('validate config (invalid YAML)', async () => { - const resp = await clientException.post(VALIDATE_ENDPOINT, 'invalid: yaml: [', { - headers: { 'Content-Type': 'application/yaml' }, + it('validate config (empty body)', async () => { + const resp = await clientException.post(VALIDATE_ENDPOINT, ''); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'invalid request body: empty request body', + }); }); - expect(resp.status).toEqual(400); - expect(resp.data).toEqual({ - error_msg: expect.stringContaining('invalid request body:'), + + it('validate config (invalid YAML)', async () => { + const resp = await clientException.post(VALIDATE_ENDPOINT, 'invalid: yaml: [', { + headers: { 'Content-Type': 'application/yaml' }, + }); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: expect.stringContaining('invalid request body:'), + }); }); - }); - it('validate config (duplicate consumer username)', async () => { - const duplicateConsumerConfig = { - consumers: [ - { - username: 'consumer1', - plugins: { - 'key-auth': { - key: 'consumer1', + it('validate config (duplicate consumer username)', async () => { + const duplicateConsumerConfig = { + consumers: [ + { + username: 'consumer1', + plugins: { + 'key-auth': { + key: 'consumer1', + }, }, }, - }, - { - username: 'consumer1', // Duplicate username - plugins: { - 'key-auth': { - key: 'consumer1', + { + username: 'consumer1', // Duplicate username + plugins: { + 'key-auth': { + key: 'consumer1', + }, }, }, - }, - ], - }; - - const resp = await clientException.post(VALIDATE_ENDPOINT, duplicateConsumerConfig); - expect(resp.status).toEqual(400); - expect(resp.data).toEqual({ - error_msg: 'Configuration validation failed', - errors: expect.arrayContaining([ - expect.objectContaining({ - resource_type: 'consumers', - error: expect.stringContaining('found duplicate username consumer1 in consumers'), - }), - ]), + ], + }; + + const resp = await clientException.post(VALIDATE_ENDPOINT, duplicateConsumerConfig); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'consumers', + error: expect.stringContaining('found duplicate username consumer1 in consumers'), + }), + ]), + }); }); - }); - it('validate config (duplicate consumer credential id)', async () => { - const duplicateCredentialConfig = { - consumers: [ - { - username: 'john_1', - }, - { - id: 'john_1/credentials/john-a', - plugins: { - 'key-auth': { - key: 'auth-a', + it('validate config (duplicate consumer credential id)', async () => { + const duplicateCredentialConfig = { + consumers: [ + { + username: 'john_1', + }, + { + id: 'john_1/credentials/john-a', + plugins: { + 'key-auth': { + key: 'auth-a', + }, }, }, - }, - { - id: 'john_1/credentials/john-a', // Duplicate credential ID - plugins: { - 'key-auth': { - key: 'auth-a', + { + id: 'john_1/credentials/john-a', // Duplicate credential ID + plugins: { + 'key-auth': { + key: 'auth-a', + }, }, }, - }, - ], - }; - - const resp = await clientException.post(VALIDATE_ENDPOINT, duplicateCredentialConfig); - expect(resp.status).toEqual(400); - expect(resp.data).toEqual({ - error_msg: 'Configuration validation failed', - errors: expect.arrayContaining([ - expect.objectContaining({ - resource_type: 'consumers', - error: expect.stringContaining('found duplicate credential id john_1/credentials/john-a in consumers'), - }), - ]), + ], + }; + + const resp = await clientException.post(VALIDATE_ENDPOINT, duplicateCredentialConfig); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'consumers', + error: expect.stringContaining('found duplicate credential id john_1/credentials/john-a in consumers'), + }), + ]), + }); }); - }); - it('validate config (invalid plugin)', async () => { - const resp = await clientException.post( - VALIDATE_ENDPOINT, - routeWithUnknownPlugins, - ); - expect(resp.status).toEqual(400); - expect(resp.data).toEqual({ - error_msg: 'Configuration validation failed', - errors: expect.arrayContaining([ - expect.objectContaining({ - resource_type: 'routes', - error: expect.stringContaining('unknown plugin [invalid-plugin]'), - }), - ]), + it('validate config (invalid plugin)', async () => { + const resp = await clientException.post( + VALIDATE_ENDPOINT, + routeWithUnknownPlugins, + ); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'routes', + error: expect.stringContaining('unknown plugin [invalid-plugin]'), + }), + ]), + }); }); - }); - it('validate config (invalid upstream)', async () => { - const resp = await clientException.post( - VALIDATE_ENDPOINT, - routeWithInvalidUpstream, - ); - expect(resp.status).toEqual(400); - expect(resp.data).toEqual({ - error_msg: 'Configuration validation failed', - errors: expect.arrayContaining([ - expect.objectContaining({ - resource_type: 'routes', - error: expect.stringContaining('failed to match pattern'), - }), - ]), + it('validate config (invalid upstream)', async () => { + const resp = await clientException.post( + VALIDATE_ENDPOINT, + routeWithInvalidUpstream, + ); + expect(resp.status).toEqual(400); + expect(resp.data).toEqual({ + error_msg: 'Configuration validation failed', + errors: expect.arrayContaining([ + expect.objectContaining({ + resource_type: 'routes', + error: expect.stringContaining('failed to match pattern'), + }), + ]), + }); }); }); }); From 40b52bce10e02588e410721625a95829f2bdd386 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Mon, 24 Nov 2025 05:51:39 +0000 Subject: [PATCH 14/14] f --- apisix/admin/standalone.lua | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/apisix/admin/standalone.lua b/apisix/admin/standalone.lua index 7ff121565c24..be08c6b823fe 100644 --- a/apisix/admin/standalone.lua +++ b/apisix/admin/standalone.lua @@ -158,10 +158,8 @@ end local function validate_configuration(req_body, collect_all_errors) - local validation_results = { - valid = true, - errors = {} - } + local is_valid = true + local validation_results = {} for key, conf_version_key in pairs(ALL_RESOURCE_KEYS) do local items = req_body[key] @@ -173,8 +171,8 @@ local function validate_configuration(req_body, collect_all_errors) if not collect_all_errors then return false, conf_version_key .. " must be a number" end - validation_results.valid = false - table_insert(validation_results.errors, { + is_valid = false + table_insert(validation_results, { resource_type = key, error = conf_version_key .. " must be a number, got " .. type(new_conf_version) }) @@ -196,8 +194,8 @@ local function validate_configuration(req_body, collect_all_errors) if not collect_all_errors then return false, error_msg end - validation_results.valid = false - table_insert(validation_results.errors, { + is_valid = false + table_insert(validation_results, { resource_type = key, index = index - 1, error = error_msg @@ -210,8 +208,8 @@ local function validate_configuration(req_body, collect_all_errors) if not collect_all_errors then return false, dup_err end - validation_results.valid = false - table_insert(validation_results.errors, { + is_valid = false + table_insert(validation_results, { resource_type = key, index = index - 1, error = dup_err @@ -222,10 +220,10 @@ local function validate_configuration(req_body, collect_all_errors) end if collect_all_errors then - return validation_results.valid, validation_results - else - return validation_results.valid, nil + return is_valid, validation_results end + + return is_valid, nil end local function validate(ctx) @@ -260,7 +258,7 @@ local function validate(ctx) if not valid then return core.response.exit(400, { error_msg = "Configuration validation failed", - errors = validation_results.errors + errors = validation_results }) end