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

Reply via email to