This is an automated email from the ASF dual-hosted git repository.

alinsran 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 0bb54e0a9 feat(standalone): support revision in API-driven standalone 
mode like etcd (#12214)
0bb54e0a9 is described below

commit 0bb54e0a9b18aaa3f2c609ebc0b6d3ea8b2c564c
Author: AlinsRan <alins...@apache.org>
AuthorDate: Tue May 20 16:16:10 2025 +0800

    feat(standalone): support revision in API-driven standalone mode like etcd 
(#12214)
---
 apisix/admin/standalone.lua        | 119 +++++++++++++++++--------
 apisix/core/config_yaml.lua        | 124 ++++++++++++++++++--------
 docs/en/latest/deployment-modes.md | 178 ++++++++++++++++++++++++++++---------
 t/admin/standalone.spec.ts         | 157 +++++++++++++++++++++++++++-----
 t/admin/standalone.t               | 115 ++++++++++++++++++++++--
 5 files changed, 542 insertions(+), 151 deletions(-)

diff --git a/apisix/admin/standalone.lua b/apisix/admin/standalone.lua
index c0e9b2288..27de24d2b 100644
--- a/apisix/admin/standalone.lua
+++ b/apisix/admin/standalone.lua
@@ -17,8 +17,6 @@
 local type         = type
 local pairs        = pairs
 local ipairs       = ipairs
-local tonumber     = tonumber
-local tostring     = tostring
 local str_lower    = string.lower
 local ngx          = ngx
 local get_method   = ngx.req.get_method
@@ -30,25 +28,42 @@ local events       = require("apisix.events")
 local core         = require("apisix.core")
 local config_yaml  = require("apisix.core.config_yaml")
 local check_schema = require("apisix.core.schema").check
+local tbl_deepcopy = require("apisix.core.table").deepcopy
 
 local EVENT_UPDATE = "standalone-api-configuration-update"
 
 local _M = {}
 
 
-local function update_and_broadcast_config(apisix_yaml, conf_version)
-    local config = core.json.encode({
-        conf = apisix_yaml,
-        conf_version = conf_version,
-    })
+local function get_config()
+    local config = shared_dict:get("config")
+    if not config then
+        return nil, "not found"
+    end
+
+    local err
+    config, err = core.json.decode(config)
+    if not config then
+        return nil, "failed to decode json: " .. err
+    end
+    return config
+end
+
+
+local function update_and_broadcast_config(apisix_yaml)
+    local raw, err = core.json.encode(apisix_yaml)
+    if not raw then
+        core.log.error("failed to encode json: ", err)
+        return nil, "failed to encode json: " .. err
+    end
 
     if shared_dict then
         -- the worker that handles Admin API calls is responsible for writing 
the shared dict
-        local ok, err = shared_dict:set("config", config)
+        local ok, err = shared_dict:set("config", raw)
         if not ok then
             return nil, "failed to save config to shared dict: " .. err
         end
-        core.log.info("standalone config updated: ", config)
+        core.log.info("standalone config updated: ", raw)
     else
         core.log.crit(config_yaml.ERR_NO_SHARED_DICT)
     end
@@ -59,26 +74,6 @@ end
 local function update(ctx)
     local content_type = core.request.header(nil, "content-type") or 
"application/json"
 
-    local conf_version
-    if ctx.var.arg_conf_version then
-        conf_version = tonumber(ctx.var.arg_conf_version)
-        if not conf_version then
-            return core.response.exit(400, {error_msg = "invalid conf_version: 
"
-                                            .. ctx.var.arg_conf_version
-                                            .. ", should be a integer" })
-        end
-    else
-        conf_version = ngx.time()
-    end
-    -- check if conf_version greater than the current version
-    local _, ver = config_yaml._get_config()
-    if conf_version <= ver then
-        return core.response.exit(400, {error_msg = "invalid conf_version: 
conf_version ("
-                                        .. conf_version
-                                        .. ") should be greater than the 
current version ("
-                                        .. ver .. ")"})
-    end
-
     -- read the request body
     local req_body, err = core.request.get_body()
     if err then
@@ -105,28 +100,63 @@ local function update(ctx)
     end
     req_body = data
 
+    local config, err = get_config()
+    if not config then
+        if err ~= "not found" 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
+    end
+
     -- check input by jsonschema
     local apisix_yaml = {}
     local created_objs = config_yaml.fetch_all_created_obj()
+
     for key, obj in pairs(created_objs) do
-        if req_body[key] and #req_body[key] > 0 then
-            apisix_yaml[key] = table_new(1, 0)
+        local conf_version_key = obj.conf_version_key
+        local conf_version = config and config[conf_version_key] or 
obj.conf_version
+        local items = req_body[key]
+        local new_conf_version = req_body[conf_version_key]
+        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 < conf_version then
+                return core.response.exit(400, {
+                    error_msg = conf_version_key ..
+                        " must be greater than or equal to (" .. conf_version 
.. ")",
+                })
+            end
+        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 = obj.item_schema
             local item_checker = obj.checker
 
-            for index, item in ipairs(req_body[key]) do
+            for index, item in ipairs(items) do
+                local item_temp = tbl_deepcopy(item)
                 local valid, err
                 -- need to recover to 0-based subscript
                 local err_prefix = "invalid " .. key .. " at index " .. (index 
- 1) .. ", err: "
                 if item_schema then
-                    valid, err = check_schema(obj.item_schema, item)
+                    valid, err = check_schema(obj.item_schema, item_temp)
                     if not valid then
                         core.log.error(err_prefix, err)
                         core.response.exit(400, {error_msg = err_prefix .. 
err})
                     end
                 end
                 if item_checker then
-                    valid, err = item_checker(item)
+                    valid, err = item_checker(item_temp)
                     if not valid then
                         core.log.error(err_prefix, err)
                         core.response.exit(400, {error_msg = err_prefix .. 
err})
@@ -137,12 +167,11 @@ local function update(ctx)
         end
     end
 
-    local ok, err = update_and_broadcast_config(apisix_yaml, conf_version)
+    local ok, err = update_and_broadcast_config(apisix_yaml)
     if not ok then
         core.response.exit(500, err)
     end
 
-    core.response.set_header("X-APISIX-Conf-Version", tostring(conf_version))
     return core.response.exit(202)
 end
 
@@ -151,9 +180,21 @@ 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")
 
-    local _, ver, config = config_yaml._get_config()
+    local config, err = get_config()
+    if not config then
+        if err ~= "not found" 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
+        config = {}
+        local created_objs = config_yaml.fetch_all_created_obj()
+        for _, obj in pairs(created_objs) do
+            config[obj.conf_version_key] = obj.conf_version
+        end
+    end
 
-    core.response.set_header("X-APISIX-Conf-Version", tostring(ver))
     local resp, err
     if want_yaml_resp then
         core.response.set_header("Content-Type", "application/yaml")
@@ -207,7 +248,7 @@ function _M.init_worker()
             core.log.error("failed to decode json: ", err)
             return
         end
-        config_yaml._update_config(config.conf, config.conf_version)
+        config_yaml._update_config(config)
     end
     events:register(update_config, EVENT_UPDATE, EVENT_UPDATE)
 end
diff --git a/apisix/core/config_yaml.lua b/apisix/core/config_yaml.lua
index 8cd16e9ac..428cdfc0d 100644
--- a/apisix/core/config_yaml.lua
+++ b/apisix/core/config_yaml.lua
@@ -25,9 +25,9 @@ local yaml         = require("lyaml")
 local log          = require("apisix.core.log")
 local json         = require("apisix.core.json")
 local new_tab      = require("table.new")
+local tbl_deepcopy = require("apisix.core.table").deepcopy
 local check_schema = require("apisix.core.schema").check
 local profile      = require("apisix.core.profile")
-local tbl_deepcopy = require("apisix.core.table").deepcopy
 local lfs          = require("lfs")
 local file         = require("apisix.cli.file")
 local exiting      = ngx.worker.exiting
@@ -70,11 +70,9 @@ local mt = {
 
 
 local apisix_yaml
-local apisix_yaml_raw -- save a deepcopy of the latest configuration for API
 local apisix_yaml_mtime
 
-
-local function update_config(table, mtime)
+local function update_config(table, conf_version)
     if not table then
         log.error("failed update config: empty table")
         return
@@ -87,18 +85,11 @@ local function update_config(table, mtime)
     end
 
     apisix_yaml = table
-    apisix_yaml_raw = tbl_deepcopy(table)
-    apisix_yaml_mtime = mtime
+    apisix_yaml_mtime = conf_version
 end
 _M._update_config = update_config
 
 
-local function get_config()
-    return apisix_yaml, apisix_yaml_mtime, apisix_yaml_raw
-end
-_M._get_config = get_config
-
-
 local function is_use_admin_api()
     local local_conf, _ = config_local.local_conf()
     return local_conf and local_conf.apisix and local_conf.apisix.enable_admin
@@ -158,29 +149,57 @@ local function sync_data(self)
         return nil, "missing 'key' arguments"
     end
 
-    if not apisix_yaml_mtime then
-        log.warn("wait for more time")
-        return nil, "failed to read local file " .. apisix_yaml_path
+    local conf_version
+    if is_use_admin_api() then
+        conf_version = apisix_yaml[self.conf_version_key] or 0
+    else
+        if not apisix_yaml_mtime then
+            log.warn("wait for more time")
+            return nil, "failed to read local file " .. apisix_yaml_path
+        end
+        conf_version = apisix_yaml_mtime
     end
 
-    if self.conf_version == apisix_yaml_mtime then
+    if not conf_version or conf_version == self.conf_version then
         return true
     end
 
     local items = apisix_yaml[self.key]
-    log.info(self.key, " items: ", json.delay_encode(items))
     if not items then
         self.values = new_tab(8, 0)
         self.values_hash = new_tab(0, 8)
-        self.conf_version = apisix_yaml_mtime
+        self.conf_version = conf_version
         return true
     end
 
-    if self.values then
-        for _, item in ipairs(self.values) do
-            config_util.fire_all_clean_handlers(item)
+    if self.values and #self.values > 0 then
+        if is_use_admin_api() then
+            -- filter self.values to retain only those whose IDs exist in the 
new items list.
+            local exist_values = new_tab(8, 0)
+            self.values_hash = new_tab(0, 8)
+
+            local exist_items = {}
+            for _, item in ipairs(items) do
+                exist_items[tostring(item.id)] = true
+            end
+            -- remove objects that exist in the self.values but do not exist 
in the new items.
+            -- for removed items, trigger cleanup handlers.
+            for _, item in ipairs(self.values) do
+                local id = item.value.id
+                if not exist_items[id]  then
+                    config_util.fire_all_clean_handlers(item)
+                else
+                    insert_tab(exist_values, item)
+                    self.values_hash[id] = #exist_values
+                end
+            end
+            self.values = exist_values
+        else
+            for _, item in ipairs(self.values) do
+                config_util.fire_all_clean_handlers(item)
+            end
+            self.values = nil
         end
-        self.values = nil
     end
 
     if self.single_item then
@@ -189,7 +208,8 @@ local function sync_data(self)
         self.values_hash = new_tab(0, 1)
 
         local item = items
-        local conf_item = {value = item, modifiedIndex = apisix_yaml_mtime,
+        local modifiedIndex = item.modifiedIndex or conf_version
+        local conf_item = {value = item, modifiedIndex = modifiedIndex,
                            key = "/" .. self.key}
 
         local data_valid = true
@@ -221,23 +241,26 @@ local function sync_data(self)
         end
 
     else
-        self.values = new_tab(#items, 0)
-        self.values_hash = new_tab(0, #items)
+        if not self.values then
+            self.values = new_tab(8, 0)
+            self.values_hash = new_tab(0, 8)
+        end
 
         local err
         for i, item in ipairs(items) do
-            local id = tostring(i)
+            local idx = tostring(i)
             local data_valid = true
             if type(item) ~= "table" then
                 data_valid = false
-                log.error("invalid item data of [", self.key .. "/" .. id,
+                log.error("invalid item data of [", self.key .. "/" .. idx,
                           "], val: ", json.delay_encode(item),
                           ", it should be an object")
             end
 
-            local key = item.id or "arr_" .. i
-            local conf_item = {value = item, modifiedIndex = apisix_yaml_mtime,
-                            key = "/" .. self.key .. "/" .. key}
+            local id = item.id or ("arr_" .. idx)
+            local modifiedIndex = item.modifiedIndex or conf_version
+            local conf_item = {value = item, modifiedIndex = modifiedIndex,
+                            key = "/" .. self.key .. "/" .. id}
 
             if data_valid and self.item_schema then
                 data_valid, err = check_schema(self.item_schema, item)
@@ -256,12 +279,24 @@ local function sync_data(self)
             end
 
             if data_valid then
-                insert_tab(self.values, conf_item)
-                local item_id = conf_item.value.id or self.key .. "#" .. id
-                item_id = tostring(item_id)
-                self.values_hash[item_id] = #self.values
-                conf_item.value.id = item_id
-                conf_item.clean_handlers = {}
+                local item_id = tostring(id)
+                local pre_index = self.values_hash[item_id]
+                if pre_index then
+                    -- remove the old item
+                    local pre_val = self.values[pre_index]
+                    if pre_val and
+                        (not item.modifiedIndex or pre_val.modifiedIndex ~= 
item.modifiedIndex) then
+                        config_util.fire_all_clean_handlers(pre_val)
+                        self.values[pre_index] = conf_item
+                        conf_item.value.id = item_id
+                        conf_item.clean_handlers = {}
+                    end
+                else
+                    insert_tab(self.values, conf_item)
+                    self.values_hash[item_id] = #self.values
+                    conf_item.value.id = item_id
+                    conf_item.clean_handlers = {}
+                end
 
                 if self.filter then
                     self.filter(conf_item)
@@ -270,7 +305,7 @@ local function sync_data(self)
         end
     end
 
-    self.conf_version = apisix_yaml_mtime
+    self.conf_version = conf_version
     return true
 end
 
@@ -317,6 +352,7 @@ local function _automatic_fetch(premature, self)
             log.info("no config found in shared dict")
             goto SKIP_SHARED_DICT
         end
+        log.info("startup config loaded from shared dict: ", config)
 
         config, err = json.decode(tostring(config))
         if not config then
@@ -324,7 +360,7 @@ local function _automatic_fetch(premature, self)
             goto SKIP_SHARED_DICT
         end
 
-        _M._update_config(config.conf, config.conf_version)
+        _M._update_config(config)
         log.info("config loaded from shared dict")
 
         ::SKIP_SHARED_DICT::
@@ -395,6 +431,16 @@ function _M.new(key, opts)
         key = sub_str(key, 2)
     end
 
+    if is_use_admin_api() then
+        if item_schema and item_schema.properties then
+            local item_schema_cp = tbl_deepcopy(item_schema)
+            -- allow clients to specify modifiedIndex to control resource 
changes.
+            item_schema_cp.properties.modifiedIndex = {
+                type = "integer",
+            }
+            item_schema = item_schema_cp
+        end
+    end
     local obj = setmetatable({
         automatic = automatic,
         item_schema = item_schema,
@@ -408,6 +454,7 @@ function _M.new(key, opts)
         last_err = nil,
         last_err_time = nil,
         key = key,
+        conf_version_key = key and key .. "_conf_version",
         single_item = single_item,
         filter = filter_fun,
     }, mt)
@@ -471,7 +518,6 @@ end
 function _M.init_worker()
     if is_use_admin_api() then
         apisix_yaml = {}
-        apisix_yaml_raw = {}
         apisix_yaml_mtime = 0
         return true
     end
diff --git a/docs/en/latest/deployment-modes.md 
b/docs/en/latest/deployment-modes.md
index bc17c4366..4e916ae14 100644
--- a/docs/en/latest/deployment-modes.md
+++ b/docs/en/latest/deployment-modes.md
@@ -115,73 +115,163 @@ This method is more suitable for two types of users:
 
 Now, we have two standalone running modes, file-driven and API-driven.
 
-1. The file-driven mode is the kind APISIX has always supported.
+#### File-driven
 
-    The routing rules in the `conf/apisix.yaml` file are loaded into memory 
immediately after the APISIX node service starts. At each interval (default: 1 
second), APISIX checks for updates to the file. If changes are detected, it 
reloads the rules.
+The file-driven mode is the kind APISIX has always supported.
 
-    *Note*: Reloading and updating routing rules are all hot memory updates. 
There is no replacement of working processes, since it's a hot update.
+The routing rules in the `conf/apisix.yaml` file are loaded into memory 
immediately after the APISIX node service starts. At each interval (default: 1 
second), APISIX checks for updates to the file. If changes are detected, it 
reloads the rules.
 
-    This requires us to set the APISIX role to data plane. That is, set 
`deployment.role` to `data_plane` and 
`deployment.role_data_plane.config_provider` to `yaml`.
+*Note*: Reloading and updating routing rules are all hot memory updates. There 
is no replacement of working processes, since it's a hot update.
 
-    Refer to the example below:
+This requires us to set the APISIX role to data plane. That is, set 
`deployment.role` to `data_plane` and 
`deployment.role_data_plane.config_provider` to `yaml`.
 
-    ```yaml
-    deployment:
-      role: data_plane
-      role_data_plane:
-        config_provider: yaml
-    #END
-    ```
+Refer to the example below:
+
+```yaml
+deployment:
+  role: data_plane
+  role_data_plane:
+    config_provider: yaml
+```
 
-    This makes it possible to disable the Admin API and discover configuration 
changes and reloads based on the local file system.
+This makes it possible to disable the Admin API and discover configuration 
changes and reloads based on the local file system.
 
-2. The API-driven is an emerging paradigm for standalone.
+#### API-driven (Experimental)
 
-    The routing rules will be entirely in memory and not in a file, and it 
will need to be updated using the dedicated Standalone Admin API.
+> This mode is experimental, please do not rely on it in your production 
environment.
+> We use it to validate certain specific workloads and if it is appropriate we 
will turn it into an officially supported feature, otherwise it will be removed.
 
-    I.e. we need to send an HTTP PUT request to this API containing the 
configuration in JSON or YAML format, which will flush the configuration used 
by each worker in the current APISIX instance.
+##### Overview
 
-    Changes will overwrite the entire configuration and take effect 
immediately without requiring a reboot, as it is hot updated.
+API-driven mode is an emerging paradigm for standalone deployment, where 
routing rules are stored entirely in memory rather than in a configuration 
file. Updates must be made through the dedicated Standalone Admin API. Each 
update replaces the full configuration and takes effect immediately through hot 
updates, without requiring a restart.
 
-    This requires us to set the APISIX role to traditional (since we need to 
start both the API gateway and the Admin API endpoint) and use the yaml config 
provider. That is, set `deployment.role` to `traditional` and 
`deployment.role_traditional.config_provider` to `yaml`.
+##### Configuration
 
-    Refer to the example below:
+To enable this mode, set the APISIX role to `traditional` (to start both the 
API gateway and the Admin API endpoint) and use the YAML config provider. 
Example configuration:
 
-    ```yaml
-    deployment:
-      role: traditional
-      role_traditional:
-        config_provider: yaml
-    #END
-    ```
+```yaml
+deployment:
+  role: traditional
+  role_traditional:
+    config_provider: yaml
+```
+
+This disables the local file source of configuration in favor of the API. When 
APISIX starts, it uses an empty configuration until updated via the API.
 
-    This disables the local file source of configuration in favor of the API. 
When APISIX starts, it uses the empty configuration until you update it via the 
API.
+##### API Endpoints
 
-    The following are API endpoints:
+* `conf_version` by resource type
 
-    ```shell
-    ## Update configuration
-    ## The conf_version is not required, if it is not entered by the client, 
the current 10-digit epoch time is used by default.
-    curl -X PUT http://127.0.0.1:9180/apisix/admin/configs?conf_version=1234 \
-        -H "X-API-KEY: <apikey>"
-        -H "Content-Type: application/json" ## or application/yaml
-        --data-binary @config.json
+    Use `<resource>_conf_version` to indicate the client’s current version for 
each resource type (e.g. routes, upstreams, services, etc.).
 
-    ## Get latest configuration
-    curl -X GET http://127.0.0.1:9180/apisix/admin/configs
-        -H "X-API-KEY: <apikey>"
-        -H "Accept: application/json" ## or application/yaml
+    ```json
+    {
+      "routes_conf_version": 12,
+      "upstreams_conf_version": 102,
+      "routes": [],
+      "upstreams": []
+    }
     ```
 
-    The update API validates the input and returns an error if it is invalid. 
If the configuration is accepted, it responds with a `202 Accepted` status and 
includes the latest configuration version in the `X-APISIX-Conf-Version` header.
+    APISIX compares each provided `<resource>_conf_version` against its 
in-memory `<resource>_conf_version` for that resource type. If the provided 
`<resource>_conf_version` is:
+
+  - **Greater than** the current `conf_version`, APISIX will **rebuild/reset** 
that resource type’s data to match your payload.
+
+  - **Equal to** the current `conf_version`, APISIX treats the resource as 
**unchanged** and **ignores** it (no data is rebuilt).
+
+  - **Less than** the current `conf_version`, APISIX considers your update 
**stale** and **rejects** the request for that resource type with a **400 Bad 
Request**.
+
+* `modifiedIndex` by individual resource
+
+    Allow setting an index for each resource. APISIX compares this index to 
its modifiedIndex to determine whether to accept the update.
+
+##### Example
+
+1. get configuration
+
+```shell
+curl -X GET http://127.0.0.1:9180/apisix/admin/configs \
+    -H "X-API-KEY: <apikey>" \
+    -H "Accept: application/json" ## or application/yaml
+```
+
+This returns the current configuration in JSON or YAML format.
+
+```json
+{
+    "consumer_groups_conf_version": 0,
+    "consumers_conf_version": 0,
+    "global_rules_conf_version": 0,
+    "plugin_configs_conf_version": 0,
+    "plugin_metadata_conf_version": 0,
+    "protos_conf_version": 0,
+    "routes_conf_version": 0,
+    "secrets_conf_version": 0,
+    "services_conf_version": 0,
+    "ssls_conf_version": 0,
+    "upstreams_conf_version": 0
+}
+```
+
+2. full update
+
+```shell
+curl -X PUT http://127.0.0.1:9180/apisix/admin/configs \
+    -H "X-API-KEY: <apikey>" \
+    -H "Content-Type: application/json" ## or application/yaml \
+    -d '{}'
+```
+
+3. update based on resource type
+
+In APISIX memory, the current configuration is:
+
+```json
+{
+    "routes_conf_version": 1000,
+    "upstreams_conf_version": 1000,
+}
+```
+
+Update the previous upstreams configuration by setting a higher version 
number, such as 1001, to replace the current version 1000:
+
+```shell
+curl -X PUT http://127.0.0.1:9180/apisix/admin/configs \
+  -H "X-API-KEY: ${API_KEY}" \
+  -H "Content-Type: application/json" \
+  -d '
+{
+    "routes_conf_version": 1000,
+    "upstreams_conf_version": 1001,
+    "routes": [
+        {
+            "modifiedIndex": 1000,
+            "id": "r1",
+            "uri": "/hello",
+            "upstream_id": "u1"
+        }
+    ],
+    "upstreams": [
+        {
+            "modifiedIndex": 1001,
+            "id": "u1",
+            "nodes": {
+                "127.0.0.1:1980": 1,
+                "127.0.0.1:1980": 1
+            },
+            "type": "roundrobin"
+        }
+    ]
+}'
+```
 
-    The get API also returns the version number via the 
`X-APISIX-Conf-Version` header, and returns a response body containing the 
configuration in a specific format as requested by the client `Accept` header.
+:::note
 
-    These APIs apply the same security requirements as the Admin API  — such 
as API key, TLS/mTLS, CORS, and IP allowlist — no changes or additions.
+These APIs apply the same security requirements as the Admin API, including 
API key, TLS/mTLS, CORS, and IP allowlist.
 
-    The API accepts input in the same format as the file-based mode described 
above, although it also allows the user to input JSON instead of just YAML. The 
following example still applies. However, the API does not rely on the `#END` 
suffix because HTTP will guarantee input integrity.
+The API accepts input in the same format as the file-based mode, supporting 
both JSON and YAML. Unlike the file-based mode, the API does not rely on the 
`#END` suffix, as HTTP guarantees input integrity.
 
-    *Note*: In this case, the Admin API based on etcd is not available. The 
configuration can only be flushed as a whole, rather than modified partially, 
and the client must send a request containing the complete new configuration to 
the API.
+:::
 
 ### How to configure rules
 
diff --git a/t/admin/standalone.spec.ts b/t/admin/standalone.spec.ts
index 5b8ed4a0b..911fbfea1 100644
--- a/t/admin/standalone.spec.ts
+++ b/t/admin/standalone.spec.ts
@@ -45,6 +45,26 @@ const config2 = {
     },
   ],
 };
+const invalidConfVersionConfig1 = {
+  routes_conf_version: -1,
+};
+const invalidConfVersionConfig2 = {
+  routes_conf_version: "adc",
+};
+const routeWithModifiedIndex = {
+  routes: [
+    {
+      id: "r1",
+      uri: "/r1",
+      modifiedIndex: 1,
+      upstream: {
+        nodes: { "127.0.0.1:1980": 1 },
+        type: "roundrobin",
+      },
+      plugins: { "proxy-rewrite": { uri: "/hello" } },
+    },
+  ],
+};
 const clientConfig = {
   baseURL: "http://localhost:1984";,
   headers: {
@@ -69,9 +89,11 @@ describe("Admin - Standalone", () => {
     it("dump empty config (default json format)", async () => {
       const resp = await client.get(ENDPOINT);
       expect(resp.status).toEqual(200);
-      expect(resp.headers["content-type"]).toEqual("application/json");
-      expect(resp.headers["x-apisix-conf-version"]).toEqual("0");
-      expect(resp.data).toEqual({});
+      expect(resp.data.routes_conf_version).toEqual(0);
+      expect(resp.data.ssls_conf_version).toEqual(0);
+      expect(resp.data.services_conf_version).toEqual(0);
+      expect(resp.data.upstreams_conf_version).toEqual(0);
+      expect(resp.data.consumers_conf_version).toEqual(0);
     });
 
     it("dump empty config (yaml format)", async () => {
@@ -80,23 +102,32 @@ describe("Admin - Standalone", () => {
       });
       expect(resp.status).toEqual(200);
       expect(resp.headers["content-type"]).toEqual("application/yaml");
-      expect(resp.headers["x-apisix-conf-version"]).toEqual("0");
-
-      // The lyaml-encoded empty Lua table becomes an array, which is 
expected, but shouldn't be
-      expect(resp.data).toEqual([]);
+      expect(resp.data.routes_conf_version).toEqual(0);
+      expect(resp.data.ssls_conf_version).toEqual(0);
+      expect(resp.data.services_conf_version).toEqual(0);
+      expect(resp.data.upstreams_conf_version).toEqual(0);
+      expect(resp.data.consumers_conf_version).toEqual(0);
     });
 
     it("update config (add routes, by json)", async () => {
-      const resp = await client.put(ENDPOINT, config1, {
-        params: { conf_version: 1 },
-      });
+      const resp = await client.put(ENDPOINT, config1);
       expect(resp.status).toEqual(202);
     });
 
     it("dump config (json format)", async () => {
       const resp = await client.get(ENDPOINT);
       expect(resp.status).toEqual(200);
-      expect(resp.headers["x-apisix-conf-version"]).toEqual("1");
+      expect(resp.data.routes_conf_version).toEqual(1);
+      expect(resp.data.ssls_conf_version).toEqual(1);
+      expect(resp.data.services_conf_version).toEqual(1);
+      expect(resp.data.upstreams_conf_version).toEqual(1);
+      expect(resp.data.consumers_conf_version).toEqual(1);
+    });
+
+    it("check default value", async () => {
+      const resp = await client.get(ENDPOINT);
+      expect(resp.status).toEqual(200);
+      expect(resp.data.routes).toEqual(config1.routes);
     });
 
     it("dump config (yaml format)", async () => {
@@ -105,7 +136,6 @@ describe("Admin - Standalone", () => {
         responseType: 'text',
       });
       expect(resp.status).toEqual(200);
-      expect(resp.headers["x-apisix-conf-version"]).toEqual("1");
       expect(resp.data).toContain("routes:")
       expect(resp.data).toContain("id: r1")
       expect(resp.data.startsWith('---')).toBe(false);
@@ -123,7 +153,6 @@ describe("Admin - Standalone", () => {
         ENDPOINT,
         YAML.stringify(config2),
         {
-          params: { conf_version: 2 },
           headers: { "Content-Type": "application/yaml" },
         }
       );
@@ -133,7 +162,11 @@ describe("Admin - Standalone", () => {
     it("dump config (json format)", async () => {
       const resp = await client.get(ENDPOINT);
       expect(resp.status).toEqual(200);
-      expect(resp.headers["x-apisix-conf-version"]).toEqual("2");
+      expect(resp.data.routes_conf_version).toEqual(2);
+      expect(resp.data.ssls_conf_version).toEqual(2);
+      expect(resp.data.services_conf_version).toEqual(2);
+      expect(resp.data.upstreams_conf_version).toEqual(2);
+      expect(resp.data.consumers_conf_version).toEqual(2);
     });
 
     it('check route "r1"', () =>
@@ -159,7 +192,83 @@ describe("Admin - Standalone", () => {
     it('check route "r2"', () =>
       expect(client.get("/r2")).rejects.toThrow(
         "Request failed with status code 404"
-      ));
+    ));
+
+    it("only set routes_conf_version", async () => {
+      const resp = await client.put(
+        ENDPOINT,
+        YAML.stringify({ routes_conf_version: 15 }),
+        {headers: {"Content-Type": "application/yaml"},
+      });
+      expect(resp.status).toEqual(202);
+
+      const resp_1 = await client.get(ENDPOINT);
+      expect(resp_1.status).toEqual(200);
+      expect(resp_1.data.routes_conf_version).toEqual(15);
+      expect(resp_1.data.ssls_conf_version).toEqual(4);
+      expect(resp_1.data.services_conf_version).toEqual(4);
+      expect(resp_1.data.upstreams_conf_version).toEqual(4);
+      expect(resp_1.data.consumers_conf_version).toEqual(4);
+
+      const resp2 = await client.put(
+        ENDPOINT,
+        YAML.stringify({ routes_conf_version: 17 }),
+        {headers: {"Content-Type": "application/yaml"},
+      });
+      expect(resp2.status).toEqual(202);
+
+      const resp2_1 = await client.get(ENDPOINT);
+      expect(resp2_1.status).toEqual(200);
+      expect(resp2_1.data.routes_conf_version).toEqual(17);
+      expect(resp2_1.data.ssls_conf_version).toEqual(5);
+      expect(resp2_1.data.services_conf_version).toEqual(5);
+      expect(resp2_1.data.upstreams_conf_version).toEqual(5);
+      expect(resp2_1.data.consumers_conf_version).toEqual(5);
+    });
+
+    it("control resource changes using modifiedIndex", async () => {
+      const c1 = structuredClone(routeWithModifiedIndex);
+      c1.routes[0].modifiedIndex = 1;
+
+      const c2 = structuredClone(c1);
+      c2.routes[0].uri = "/r2";
+
+      const c3 = structuredClone(c2);
+      c3.routes[0].modifiedIndex = 2;
+
+      // Update with c1
+      const resp = await client.put(ENDPOINT, c1);
+      expect(resp.status).toEqual(202);
+
+      // Check route /r1 exists
+      const resp_1 = await client.get("/r1");
+      expect(resp_1.status).toEqual(200);
+
+      // Update with c2
+      const resp2 = await client.put(ENDPOINT, c2);
+      expect(resp2.status).toEqual(202);
+
+      // Check route /r1 exists
+      // But it is not applied because the modifiedIndex is the same as the 
old value
+      const resp2_2 = await client.get("/r1");
+      expect(resp2_2.status).toEqual(200);
+
+      // Check route /r2 not exists
+      const resp2_1 = await client.get("/r2").catch((err) => err.response);
+      expect(resp2_1.status).toEqual(404);
+
+      // Update with c3
+      const resp3 = await client.put(ENDPOINT, c3);
+      expect(resp3.status).toEqual(202);
+
+      // Check route /r1 not exists
+      const resp3_1 = await client.get("/r1").catch((err) => err.response);
+      expect(resp3_1.status).toEqual(404);
+
+      // Check route /r2 exists
+      const resp3_2 = await client.get("/r2");
+      expect(resp3_2.status).toEqual(200);
+    });
   });
 
   describe("Exceptions", () => {
@@ -171,31 +280,33 @@ describe("Admin - Standalone", () => {
     it("update config (lower conf_version)", async () => {
       const resp = await clientException.put(
         ENDPOINT,
-        YAML.stringify(config2),
+        YAML.stringify(invalidConfVersionConfig1),
         {
-          params: { conf_version: 0 },
-          headers: { "Content-Type": "application/yaml" },
+          headers: {
+            "Content-Type": "application/yaml",
+          },
         }
       );
       expect(resp.status).toEqual(400);
       expect(resp.data).toEqual({
         error_msg:
-          "invalid conf_version: conf_version (0) should be greater than the 
current version (3)",
+          "routes_conf_version must be greater than or equal to (20)",
       });
     });
 
     it("update config (invalid conf_version)", async () => {
       const resp = await clientException.put(
         ENDPOINT,
-        YAML.stringify(config2),
+        YAML.stringify(invalidConfVersionConfig2),
         {
-          params: { conf_version: "abc" },
-          headers: { "Content-Type": "application/yaml" },
+          headers: {
+            "Content-Type": "application/yaml",
+          },
         }
       );
       expect(resp.status).toEqual(400);
       expect(resp.data).toEqual({
-        error_msg: "invalid conf_version: abc, should be a integer",
+        error_msg: "routes_conf_version must be a number",
       });
     });
 
diff --git a/t/admin/standalone.t b/t/admin/standalone.t
index b5c9726e0..f34059f97 100644
--- a/t/admin/standalone.t
+++ b/t/admin/standalone.t
@@ -64,11 +64,71 @@ qr/PASS admin\/standalone.spec.ts/
 
 
 
-=== TEST 2: configure route
+=== TEST 2: init conf_version
 --- config
     location /t {} # force the worker to restart by changing the configuration
 --- request
-PUT /apisix/admin/configs?conf_version=101
+PUT /apisix/admin/configs
+{
+    "consumer_groups_conf_version": 1000,
+    "consumers_conf_version": 1000,
+    "global_rules_conf_version": 1000,
+    "plugin_configs_conf_version": 1000,
+    "plugin_metadata_conf_version": 1000,
+    "protos_conf_version": 1000,
+    "routes_conf_version": 1000,
+    "secrets_conf_version": 1000,
+    "services_conf_version": 1000,
+    "ssls_conf_version": 1000,
+    "upstreams_conf_version": 1000
+}
+--- more_headers
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+--- error_code: 202
+
+
+
+=== TEST 3: get config
+--- config
+    location /t {
+        content_by_lua_block {
+            local json = require("toolkit.json")
+            local t = require("lib.test_admin")
+            local code, body = t.test('/apisix/admin/configs',
+                ngx.HTTP_GET,
+                nil,
+                [[{
+                    "consumer_groups_conf_version": 1000,
+                    "consumers_conf_version": 1000,
+                    "global_rules_conf_version": 1000,
+                    "plugin_configs_conf_version": 1000,
+                    "plugin_metadata_conf_version": 1000,
+                    "protos_conf_version": 1000,
+                    "routes_conf_version": 1000,
+                    "secrets_conf_version": 1000,
+                    "services_conf_version": 1000,
+                    "ssls_conf_version": 1000,
+                    "upstreams_conf_version": 1000
+                }]],
+                {
+                    ["X-API-KEY"] = "edd1c9f034335f136f87ad84b625c8f1"
+                }
+            )
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 4: configure route
+--- config
+    location /t {} # force the worker to restart by changing the configuration
+--- request
+PUT /apisix/admin/configs
 
{"routes":[{"id":"r1","uri":"/r1","upstream":{"nodes":{"127.0.0.1:1980":1},"type":"roundrobin"},"plugins":{"proxy-rewrite":{"uri":"/hello"}}}]}
 --- more_headers
 X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
@@ -76,7 +136,7 @@ X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
 
 
 
-=== TEST 3: test route
+=== TEST 5: test route
 --- config
     location /t1 {}
 --- request
@@ -87,11 +147,11 @@ hello world
 
 
 
-=== TEST 4: remove route
+=== TEST 6: remove route
 --- config
     location /t2 {}
 --- request
-PUT /apisix/admin/configs?conf_version=102
+PUT /apisix/admin/configs
 {}
 --- more_headers
 X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
@@ -99,9 +159,52 @@ X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
 
 
 
-=== TEST 5: test non-exist route
+=== TEST 7: test non-exist route
 --- config
     location /t3 {}
 --- request
 GET /r1
 --- error_code: 404
+
+
+
+=== TEST 8: route references upstream, but only updates the route
+--- config
+    location /t6 {}
+--- pipelined_requests eval
+[
+    "PUT /apisix/admin/configs\n" . 
"{\"routes_conf_version\":1060,\"upstreams_conf_version\":1060,\"routes\":[{\"id\":\"r1\",\"uri\":\"/r1\",\"upstream_id\":\"u1\",\"plugins\":{\"proxy-rewrite\":{\"uri\":\"/hello\"}}}],\"upstreams\":[{\"id\":\"u1\",\"nodes\":{\"127.0.0.1:1980\":1},\"type\":\"roundrobin\"}]}",
+    "PUT /apisix/admin/configs\n" . 
"{\"routes_conf_version\":1062,\"upstreams_conf_version\":1060,\"routes\":[{\"id\":\"r1\",\"uri\":\"/r2\",\"upstream_id\":\"u1\",\"plugins\":{\"proxy-rewrite\":{\"uri\":\"/hello\"}}}],\"upstreams\":[{\"id\":\"u1\",\"nodes\":{\"127.0.0.1:1980\":1},\"type\":\"roundrobin\"}]}"
+]
+--- more_headers eval
+[
+    "X-API-KEY: edd1c9f034335f136f87ad84b625c8f1",
+    "X-API-KEY: edd1c9f034335f136f87ad84b625c8f1\n" . 
"x-apisix-conf-version-routes: 100",
+]
+--- error_code eval
+[202, 202]
+
+
+
+=== TEST 9: hit r2
+--- config
+    location /t3 {}
+--- pipelined_requests eval
+["GET /r1", "GET /r2"]
+--- error_code eval
+[404, 200]
+
+
+
+=== TEST 10: routes_conf_version < 1062 is not allowed
+--- config
+    location /t {}
+--- request
+PUT /apisix/admin/configs
+{"routes_conf_version":1,"routes":[{"id":"r1","uri":"/r2","upstream_id":"u1","plugins":{"proxy-rewrite":{"uri":"/hello"}}}]}
+--- more_headers
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+x-apisix-conf-version-routes: 100
+--- error_code: 400
+--- response_body
+{"error_msg":"routes_conf_version must be greater than or equal to (1062)"}


Reply via email to