This is an automated email from the ASF dual-hosted git repository.
baoyuan 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 896d3c389 feat: add validate API to standalone mode (#12718)
896d3c389 is described below
commit 896d3c389196e7a06fddeb5c03006bf300fa4704
Author: Ashish Tiwari <[email protected]>
AuthorDate: Tue Nov 25 08:06:53 2025 +0530
feat: add validate API to standalone mode (#12718)
---
apisix/admin/init.lua | 5 +
apisix/admin/standalone.lua | 198 +++++++++++++++++++++--------
t/admin/standalone.spec.ts | 301 ++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 449 insertions(+), 55 deletions(-)
diff --git a/apisix/admin/init.lua b/apisix/admin/init.lua
index 613efbc75..de10d92d0 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 abe03de62..be08c6b82 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.
@@ -22,13 +21,13 @@ 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 table_new = require("table.new")
local yaml = require("lyaml")
local events = require("apisix.events")
local core = require("apisix.core")
@@ -158,6 +157,114 @@ local function check_conf(checker, schema, item, typ)
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 function update(ctx)
-- check digest header existence
local digest = core.request.header(nil, METADATA_DIGEST)
@@ -195,13 +302,11 @@ local function update(ctx)
req_body = data
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)
- return core.response.exit(500, {
- error_msg = "failed to get config from shared dict: " .. err
- })
- end
+ 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
-- if the client passes in the same digest, the configuration is not
updated
@@ -211,58 +316,35 @@ local function update(ctx)
return core.response.exit(204)
end
- -- check input by jsonschema
+ 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 not new_conf_version then
- new_conf_version = conf_version + 1
- else
- 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 then
if new_conf_version < conf_version then
return core.response.exit(400, {
error_msg = conf_version_key ..
" 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]
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
- 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 })
- end
-
- table_insert(apisix_yaml[key], item)
- end
+ apisix_yaml[key] = items
end
end
@@ -280,7 +362,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")
@@ -288,9 +369,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 = {}
@@ -330,14 +411,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
@@ -347,20 +427,28 @@ 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())
if method == "put" then
return update(ctx)
- elseif method == "head" then
- return head(ctx)
- else
- return get(ctx)
end
-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
+ end
+
+ if method == "head" then
+ return head(ctx)
+ end
+ return get(ctx)
+end
local patch_schema
do
local resource_schema = {
diff --git a/t/admin/standalone.spec.ts b/t/admin/standalone.spec.ts
index 31addaebd..d61fb1b37 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 = {
@@ -643,3 +644,303 @@ 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);
+ 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',
+ 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(VALIDATE_ENDPOINT, multiResourceConfig);
+ expect(resp.status).toEqual(200);
+ });
+
+ 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', () => {
+ const clientException = axios.create({
+ ...clientConfig,
+ validateStatus: () => true,
+ });
+ 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(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'
+ },
+ },
+ ],
+ };
+
+ 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',
+ },
+ },
+ ],
+ 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 (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 (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',
+ },
+ },
+ },
+ ],
+ };
+
+ 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',
+ },
+ },
+ },
+ {
+ 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'),
+ }),
+ ]),
+ });
+ });
+
+ 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'),
+ }),
+ ]),
+ });
+ });
+ });
+});