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

kayx23 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 6205e4a568 docs: add source-verified Control API contract (#14131)
6205e4a568 is described below

commit 6205e4a568106eea8277f61ae6ef942bbff62d97
Author: Traky Deng <[email protected]>
AuthorDate: Mon Nov 2 16:02:37 2026 +0800

    docs: add source-verified Control API contract (#14131)
    
    * docs: add source-verified Control API contract
    
    * docs: validate Control API response variants
    
    * docs: validate Control API examples
    
    * docs: validate complete Control API examples
    
    * docs: validate schema response example
---
 .github/workflows/admin-api-contract.yml |    3 +-
 CONTRIBUTING.md                          |    9 +-
 docs/en/latest/control-api.json          | 1533 ++++++++++++++++++++++++++++++
 t/openapi/test_control_validator.py      |  189 ++++
 utils/validate-control-api.py            |  315 ++++++
 5 files changed, 2047 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/admin-api-contract.yml 
b/.github/workflows/admin-api-contract.yml
index 0840d6bfb4..2af40bb82b 100644
--- a/.github/workflows/admin-api-contract.yml
+++ b/.github/workflows/admin-api-contract.yml
@@ -13,7 +13,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-name: Admin API Contract
+name: APISIX API Contracts
 
 on:
   pull_request:
@@ -36,3 +36,4 @@ jobs:
       - run: python -m pip install -r utils/requirements-admin-api.txt
       - run: python -m unittest discover -s t/openapi -v
       - run: python utils/validate-admin-api.py
+      - run: python utils/validate-control-api.py
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b027340e44..130885c57f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -138,19 +138,26 @@ Once we've discussed your changes and you've got your 
code ready, make sure that
       Please take a look at `t/plugin/limit-conn.t` and 
`t/plugin/limit-conn2.t`.
     * For more details, see the [testing 
framework](https://github.com/apache/apisix/blob/master/docs/en/latest/internal/testing-framework.md)
 
-## Admin API Contract
+## API Contracts
 
 When changing Admin API routes, schemas, validation, or responses, review and 
update
 `docs/en/latest/admin-api.json` in the same PR. Implementation and tests are 
authoritative.
 Keep the contract explicit; do not fetch or generate it from downstream 
documentation.
 Plugin-specific schemas remain available through the schema endpoints.
 
+When changing Control API routes or responses, also review
+`docs/en/latest/control-api.json`. The contract documents the built-in API and 
stable
+patterns registered by shipped optional components. Qualify 
configuration-dependent
+endpoints in their operation descriptions; plugin-specific endpoints remain 
documented
+with their plugins.
+
 Run the contract checks from the repository root:
 
 ```shell
 python3 -m pip install -r utils/requirements-admin-api.txt
 python3 -m unittest discover -s t/openapi -v
 python3 utils/validate-admin-api.py
+python3 utils/validate-control-api.py
 ```
 
 The validator checks OpenAPI 3.1, local references, operation IDs, path 
parameters,
diff --git a/docs/en/latest/control-api.json b/docs/en/latest/control-api.json
new file mode 100644
index 0000000000..988be5d6ad
--- /dev/null
+++ b/docs/en/latest/control-api.json
@@ -0,0 +1,1533 @@
+{
+  "openapi": "3.1.0",
+  "info": {
+    "title": "APISIX Control API",
+    "description": "Use the APISIX Control API to inspect or control the 
runtime state of one APISIX instance. It is enabled by default at 
`http://127.0.0.1:9090`; configure `apisix.enable_control` and `apisix.control` 
in 
[`conf/config.yaml`](https://github.com/apache/apisix/blob/master/conf/config.yaml.example).
 The API does not provide authentication, so keep it on a loopback or otherwise 
trusted internal interface and never expose it to public traffic.\n\nMost 
endpoints in this refere [...]
+    "version": "3.19.0",
+    "license": {
+      "name": "Apache 2.0",
+      "url": "https://www.apache.org/licenses/LICENSE-2.0.html";
+    }
+  },
+  "security": [],
+  "tags": [
+    {
+      "name": "Schema",
+      "description": "The schema defines the available parameters as well as 
their data types, properties, default values, valid values, and so on."
+    },
+    {
+      "name": "Health Check",
+      "description": "Health checking is a mechanism that determines whether 
upstream services are healthy or unhealthy based on their responsiveness."
+    },
+    {
+      "name": "Garbage Collection",
+      "description": "Garbage collection is a form of automatic memory 
management. The garbage collector attempts to reclaim memory which was 
allocated by the program, but is no longer referenced."
+    },
+    {
+      "name": "Route",
+      "description": "A route defines a path to one or more upstream services. 
 \n\nSee 
[Routes](https://apisix.apache.org/docs/apisix/next/terminology/route/) for 
more information."
+    },
+    {
+      "name": "Upstream",
+      "description": "An upstream is a logical abstraction of one or more 
upstream addresses.  \n\nSee 
[Upstreams](https://apisix.apache.org/docs/apisix/next/terminology/upstream/) 
for more information."
+    },
+    {
+      "name": "Service",
+      "description": "A service is a backend application providing a set of 
functionalities.  \n\nSee 
[Services](https://apisix.apache.org/docs/apisix/next/terminology/service/) for 
more information."
+    },
+    {
+      "name": "Plugin Metadata",
+      "description": "Plugin metadata is used to configure the common metadata 
field(s) of all plugin instances sharing the same plugin name.  \nSee [Plugin 
Metadata](https://apisix.apache.org/docs/apisix/next/terminology/plugin-metadata/)
 for more information."
+    },
+    {
+      "name": "Plugin",
+      "description": "Plugins extend base functionalities of APISIX to meet 
user-specific requirements.  \n  \nSee 
[Plugins](https://apisix.apache.org/docs/apisix/next/terminology/plugin/) for 
more information."
+    },
+    {
+      "name": "Service Discovery",
+      "description": "Service discovery is a process used in 
microservice-based architectures for automatically detecting and registering 
dynamically assigned upstream addresses. \n\nSee [Service 
Discovery](https://apisix.apache.org/docs/apisix/next/discovery/#embedded-control-api-for-debugging)
 for more information."
+    },
+    {
+      "name": "Server Information",
+      "description": "The `server-info` plugin adds an endpoint that reports 
information about the local APISIX instance. This plugin is deprecated in 
APISIX 3.13.0."
+    }
+  ],
+  "paths": {
+    "/v1/schema": {
+      "get": {
+        "summary": "Get JSON Schema",
+        "operationId": "getSchema",
+        "deprecated": false,
+        "description": "Get the JSON schema used by the gateway instance. Only 
loaded plugins are included.",
+        "tags": [
+          "Schema"
+        ],
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": "OK",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object"
+                },
+                "examples": {
+                  "successful": {
+                    "summary": "Abbreviated Successful Response",
+                    "value": {
+                      "main": {
+                        "route": {
+                          "type": "object",
+                          "properties": {
+                            "uri": {
+                              "type": "string",
+                              "minLength": 1,
+                              "maxLength": 4096
+                            },
+                            "uris": {
+                              "type": "array",
+                              "items": {
+                                "description": "HTTP uri",
+                                "type": "string"
+                              },
+                              "minItems": 1,
+                              "uniqueItems": true
+                            },
+                            "priority": {
+                              "type": "integer",
+                              "default": 0
+                            }
+                          }
+                        },
+                        "upstream": {
+                          "type": "object",
+                          "properties": {
+                            "retries": {
+                              "type": "integer",
+                              "minimum": 0
+                            },
+                            "retry_timeout": {
+                              "type": "number",
+                              "minimum": 0
+                            },
+                            "type": {
+                              "description": "algorithms of load balancing",
+                              "type": "string",
+                              "default": "roundrobin"
+                            }
+                          }
+                        }
+                      },
+                      "plugins": {
+                        "example-plugin": {
+                          "version": 0.1,
+                          "priority": 0,
+                          "schema": {
+                            "type": "object",
+                            "properties": {
+                              "_meta": {
+                                "type": "object",
+                                "properties": {
+                                  "disable": {
+                                    "type": "boolean"
+                                  },
+                                  "error_response": {
+                                    "oneOf": [
+                                      {
+                                        "type": "string"
+                                      },
+                                      {
+                                        "type": "object"
+                                      }
+                                    ]
+                                  },
+                                  "priority": {
+                                    "description": "priority of plugins by 
customized order",
+                                    "type": "integer"
+                                  },
+                                  "filter": {
+                                    "description": "filter determines whether 
the plugin needs to be executed at runtime",
+                                    "type": "array"
+                                  },
+                                  "pre_function": {
+                                    "description": "function to be executed in 
each phase before execution of plugins. The pre_function will have access to 
two arguments: `conf` and `ctx`.",
+                                    "type": "string"
+                                  }
+                                },
+                                "additionalProperties": false
+                              },
+                              "i": {
+                                "type": "number",
+                                "minimum": 0
+                              },
+                              "s": {
+                                "type": "string"
+                              },
+                              "t": {
+                                "type": "array",
+                                "minItems": 1
+                              },
+                              "ip": {
+                                "type": "string"
+                              },
+                              "port": {
+                                "type": "integer"
+                              }
+                            },
+                            "required": [
+                              "i"
+                            ],
+                            "$comment": "this is a mark for our injected 
plugin schema"
+                          },
+                          "metadata_schema": {
+                            "type": "object",
+                            "properties": {
+                              "ikey": {
+                                "type": "number",
+                                "minimum": 0
+                              },
+                              "skey": {
+                                "type": "string"
+                              }
+                            },
+                            "required": [
+                              "ikey",
+                              "skey"
+                            ]
+                          }
+                        }
+                      },
+                      "stream_plugins": {
+                        "mqtt-proxy": {
+                          "schema": {
+                            "type": "object",
+                            "properties": {
+                              "_meta": {
+                                "type": "object",
+                                "properties": {
+                                  "disable": {
+                                    "type": "boolean"
+                                  },
+                                  "error_response": {
+                                    "oneOf": [
+                                      {
+                                        "type": "string"
+                                      },
+                                      {
+                                        "type": "object"
+                                      }
+                                    ]
+                                  },
+                                  "priority": {
+                                    "description": "priority of plugins by 
customized order",
+                                    "type": "integer"
+                                  },
+                                  "filter": {
+                                    "description": "filter determines whether 
the plugin needs to be executed at runtime",
+                                    "type": "array"
+                                  },
+                                  "pre_function": {
+                                    "description": "function to be executed in 
each phase before execution of plugins. The pre_function will have access to 
two arguments: `conf` and `ctx`.",
+                                    "type": "string"
+                                  }
+                                },
+                                "additionalProperties": false
+                              },
+                              "protocol_name": {
+                                "type": "string",
+                                "default": "MQTT"
+                              },
+                              "protocol_level": {
+                                "type": "integer"
+                              }
+                            },
+                            "required": [
+                              "protocol_level"
+                            ],
+                            "$comment": "this is a mark for our injected 
plugin schema"
+                          },
+                          "priority": 1000,
+                          "version": 0.1
+                        }
+                      }
+                    },
+                    "description": "Representative fields are shown for the 
large Route and Upstream schemas. The endpoint returns their complete runtime 
schemas."
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/v1/healthcheck": {
+      "get": {
+        "summary": "Get Health Check Information",
+        "operationId": "getHealthChecks",
+        "deprecated": false,
+        "description": "Get health check information for this APISIX instance. 
Send traffic to initialize upstream checker targets. The listing also includes 
checkers exposed by enabled plugins, such as `ai-proxy-multi`. An empty 
listing, and a checker with no targets, use JSON arrays (`[]`). Each plugin 
entry identifies the owning plugin in `plugin` and can include plugin-defined 
`meta` information. This is local runtime state, not fleet-wide readiness.",
+        "tags": [
+          "Health Check"
+        ],
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": "OK",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "array",
+                  "items": {
+                    "type": "object"
+                  }
+                },
+                "examples": {
+                  "1": {
+                    "summary": "etcd Provider Example",
+                    "value": [
+                      {
+                        "nodes": [
+                          {
+                            "ip": "52.86.68.46",
+                            "counter": {
+                              "http_failure": 0,
+                              "success": 0,
+                              "timeout_failure": 0,
+                              "tcp_failure": 0
+                            },
+                            "port": 80,
+                            "status": "healthy",
+                            "hostname": "52.86.68.46"
+                          },
+                          {
+                            "ip": "100.24.156.8",
+                            "counter": {
+                              "http_failure": 5,
+                              "success": 0,
+                              "timeout_failure": 0,
+                              "tcp_failure": 0
+                            },
+                            "port": 80,
+                            "status": "unhealthy",
+                            "hostname": "100.24.156.8"
+                          }
+                        ],
+                        "name": "/apisix/routes/1",
+                        "type": "http"
+                      }
+                    ]
+                  }
+                }
+              },
+              "text/html": {
+                "schema": {
+                  "type": "string"
+                },
+                "example": "<html><body><table>...</table></body></html>"
+              }
+            }
+          },
+          "503": {
+            "description": "Failed to render the HTML health-check response.",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "required": [
+                    "error_msg"
+                  ],
+                  "properties": {
+                    "error_msg": {
+                      "type": "string",
+                      "pattern": "^HTML template rendering: .+$",
+                      "description": "Lua template rendering error prefixed 
with `HTML template rendering: `."
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/v1/healthcheck/{src_type}/{src_id}/checkers": {
+      "get": {
+        "summary": "List All Checkers Owned by a Resource",
+        "operationId": "getResourceHealthCheckers",
+        "description": "List the upstream checker and checkers exposed by 
enabled plugins on this resource. Returns an empty array for an existing 
resource without checkers. Does not follow a route's service or upstream 
references; query the owning resource. The Control API has no authentication 
and must remain on a trusted interface.",
+        "tags": [
+          "Health Check"
+        ],
+        "parameters": [
+          {
+            "name": "src_type",
+            "in": "path",
+            "required": true,
+            "schema": {
+              "type": "string",
+              "enum": [
+                "routes",
+                "services",
+                "upstreams",
+                "stream_routes"
+              ]
+            }
+          },
+          {
+            "name": "src_id",
+            "in": "path",
+            "required": true,
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Checkers owned by the resource, including an empty 
array (`[]`) when none are configured.",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "array",
+                  "items": {
+                    "$ref": "#/components/schemas/HealthChecker"
+                  }
+                },
+                "examples": {
+                  "empty": {
+                    "value": []
+                  },
+                  "plugin": {
+                    "value": [
+                      {
+                        "name": 
"/apisix/routes/ai#plugins['ai-proxy-multi'].instances[0]",
+                        "plugin": "ai-proxy-multi",
+                        "meta": {
+                          "instance": "primary"
+                        },
+                        "type": "http",
+                        "nodes": []
+                      }
+                    ],
+                    "summary": "etcd Provider Example"
+                  }
+                }
+              },
+              "text/html": {
+                "schema": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "400": {
+            "description": "Invalid resource type or subresource.",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "required": [
+                    "error_msg"
+                  ],
+                  "properties": {
+                    "error_msg": {
+                      "type": "string",
+                      "example": "invalid src type route"
+                    }
+                  }
+                }
+              }
+            }
+          },
+          "404": {
+            "description": "Resource not found.",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "required": [
+                    "error_msg"
+                  ],
+                  "properties": {
+                    "error_msg": {
+                      "type": "string",
+                      "example": "routes[1] not found"
+                    }
+                  }
+                }
+              }
+            }
+          },
+          "503": {
+            "description": "Failed to render the HTML response.",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "required": [
+                    "error_msg"
+                  ],
+                  "properties": {
+                    "error_msg": {
+                      "type": "string",
+                      "pattern": "^HTML template rendering: .+$",
+                      "description": "Lua template rendering error prefixed 
with `HTML template rendering: `."
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/v1/healthcheck/{src_type}/{src_id}": {
+      "get": {
+        "summary": "Get Health Status By Type and ID",
+        "operationId": "getResourceHealthCheck",
+        "deprecated": false,
+        "description": "Get the single upstream health checker for a resource. 
This endpoint retains an object response and returns 404 when the resource has 
no upstream checker, even if a plugin owns checkers. Use the `/checkers` 
subresource to list both upstream and plugin-owned checkers.",
+        "tags": [
+          "Health Check"
+        ],
+        "parameters": [
+          {
+            "name": "src_type",
+            "in": "path",
+            "description": "The specified resource type.",
+            "required": true,
+            "example": "routes",
+            "schema": {
+              "type": "string",
+              "enum": [
+                "routes",
+                "services",
+                "upstreams",
+                "stream_routes"
+              ]
+            }
+          },
+          {
+            "name": "src_id",
+            "in": "path",
+            "description": "The specified node ID.",
+            "required": true,
+            "example": "1",
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "OK",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object"
+                },
+                "examples": {
+                  "1": {
+                    "summary": "etcd Provider Example",
+                    "value": {
+                      "nodes": [
+                        {
+                          "ip": "52.86.68.46",
+                          "counter": {
+                            "http_failure": 0,
+                            "success": 2,
+                            "timeout_failure": 0,
+                            "tcp_failure": 0
+                          },
+                          "port": 80,
+                          "status": "healthy",
+                          "hostname": "52.86.68.46"
+                        },
+                        {
+                          "ip": "100.24.156.8",
+                          "counter": {
+                            "http_failure": 5,
+                            "success": 0,
+                            "timeout_failure": 0,
+                            "tcp_failure": 0
+                          },
+                          "port": 80,
+                          "status": "unhealthy",
+                          "hostname": "100.24.156.8"
+                        }
+                      ],
+                      "type": "http",
+                      "name": "/apisix/routes/1"
+                    }
+                  }
+                }
+              },
+              "text/html": {
+                "schema": {
+                  "type": "string"
+                },
+                "example": "<html><body><table>...</table></body></html>"
+              }
+            }
+          },
+          "400": {
+            "description": "Invalid src type",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "required": [
+                    "error_msg"
+                  ],
+                  "properties": {
+                    "error_msg": {
+                      "type": "string",
+                      "example": "invalid src type route"
+                    }
+                  }
+                }
+              }
+            }
+          },
+          "404": {
+            "description": "Resource or health checker not found",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "required": [
+                    "error_msg"
+                  ],
+                  "properties": {
+                    "error_msg": {
+                      "type": "string",
+                      "example": "routes[1] not found"
+                    }
+                  }
+                }
+              }
+            }
+          },
+          "503": {
+            "description": "Failed to render the HTML health-check response.",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "required": [
+                    "error_msg"
+                  ],
+                  "properties": {
+                    "error_msg": {
+                      "type": "string",
+                      "pattern": "^HTML template rendering: .+$",
+                      "description": "Lua template rendering error prefixed 
with `HTML template rendering: `."
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/v1/gc": {
+      "post": {
+        "summary": "Trigger Garbage Collection",
+        "operationId": "triggerGarbageCollection",
+        "deprecated": false,
+        "description": "Trigger a full garbage collection (GC) in the HTTP 
subsystem. Note that a request to this endpoint would not trigger a garbage 
collection in the stream subsystem because the subsystems are run in the 
separate Lua VM.",
+        "tags": [
+          "Garbage Collection"
+        ],
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": "Garbage collection triggered."
+          }
+        }
+      }
+    },
+    "/v1/routes": {
+      "get": {
+        "summary": "Get All Routes",
+        "operationId": "listRoutes",
+        "deprecated": false,
+        "description": "Get all configured routes.",
+        "tags": [
+          "Route"
+        ],
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": "OK",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "oneOf": [
+                    {
+                      "type": "array",
+                      "items": {
+                        "type": "object"
+                      }
+                    },
+                    {
+                      "type": "object",
+                      "maxProperties": 0,
+                      "description": "Empty collection before any matching 
runtime configuration exists."
+                    }
+                  ]
+                },
+                "examples": {
+                  "1": {
+                    "summary": "YAML Provider Example",
+                    "value": [
+                      {
+                        "value": {
+                          "priority": 0,
+                          "uris": [
+                            "/hello"
+                          ],
+                          "id": "1",
+                          "upstream": {
+                            "scheme": "http",
+                            "pass_host": "pass",
+                            "nodes": [
+                              {
+                                "port": 1980,
+                                "host": "127.0.0.1",
+                                "weight": 1
+                              }
+                            ],
+                            "type": "roundrobin",
+                            "hash_on": "vars"
+                          },
+                          "status": 1
+                        },
+                        "has_domain": false,
+                        "orig_modifiedIndex": 1631193445,
+                        "modifiedIndex": 1631193445,
+                        "key": "/routes/1"
+                      }
+                    ]
+                  },
+                  "empty": {
+                    "summary": "Empty Collection",
+                    "value": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/v1/route/{route_id}": {
+      "get": {
+        "summary": "Get Route by ID",
+        "operationId": "getRoute",
+        "deprecated": false,
+        "description": "Get a route by ID.",
+        "tags": [
+          "Route"
+        ],
+        "parameters": [
+          {
+            "name": "route_id",
+            "in": "path",
+            "description": "Route ID.",
+            "required": true,
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "OK",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object"
+                },
+                "examples": {
+                  "1": {
+                    "summary": "YAML Provider Example",
+                    "value": {
+                      "value": {
+                        "priority": 0,
+                        "uris": [
+                          "/hello"
+                        ],
+                        "id": "1",
+                        "upstream": {
+                          "scheme": "http",
+                          "pass_host": "pass",
+                          "nodes": [
+                            {
+                              "port": 1980,
+                              "host": "127.0.0.1",
+                              "weight": 1
+                            }
+                          ],
+                          "type": "roundrobin",
+                          "hash_on": "vars"
+                        },
+                        "status": 1
+                      },
+                      "has_domain": false,
+                      "orig_modifiedIndex": 1631193445,
+                      "modifiedIndex": 1631193445,
+                      "key": "/routes/1"
+                    }
+                  }
+                }
+              }
+            }
+          },
+          "404": {
+            "description": "Route not found",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "required": [
+                    "error_msg"
+                  ],
+                  "properties": {
+                    "error_msg": {
+                      "type": "string",
+                      "example": "route[2] not found"
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/v1/services": {
+      "get": {
+        "summary": "Get All Services",
+        "operationId": "listServices",
+        "deprecated": false,
+        "description": "Get all configured services.",
+        "tags": [
+          "Service"
+        ],
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": "OK",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "oneOf": [
+                    {
+                      "type": "array",
+                      "items": {
+                        "type": "object"
+                      }
+                    },
+                    {
+                      "type": "object",
+                      "maxProperties": 0,
+                      "description": "Empty collection before any matching 
runtime configuration exists."
+                    }
+                  ]
+                },
+                "examples": {
+                  "1": {
+                    "summary": "etcd Provider Example",
+                    "value": [
+                      {
+                        "has_domain": false,
+                        "modifiedIndex": 671,
+                        "key": "/apisix/services/200",
+                        "createdIndex": 671,
+                        "value": {
+                          "upstream": {
+                            "scheme": "http",
+                            "hash_on": "vars",
+                            "pass_host": "pass",
+                            "type": "roundrobin",
+                            "nodes": [
+                              {
+                                "port": 1980,
+                                "weight": 1,
+                                "host": "127.0.0.1"
+                              }
+                            ]
+                          },
+                          "create_time": 1634552648,
+                          "id": "200",
+                          "plugins": {
+                            "limit-count": {
+                              "key": "remote_addr",
+                              "time_window": 60,
+                              "redis_timeout": 1000,
+                              "allow_degradation": false,
+                              "show_limit_quota_header": true,
+                              "policy": "local",
+                              "count": 2,
+                              "rejected_code": 503
+                            }
+                          },
+                          "update_time": 1634552648
+                        }
+                      }
+                    ]
+                  },
+                  "empty": {
+                    "summary": "Empty Collection",
+                    "value": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/v1/service/{service_id}": {
+      "get": {
+        "summary": "Get Service by ID",
+        "operationId": "getService",
+        "deprecated": false,
+        "description": "Get a service by ID.",
+        "tags": [
+          "Service"
+        ],
+        "parameters": [
+          {
+            "name": "service_id",
+            "in": "path",
+            "description": "Service ID.",
+            "required": true,
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "OK",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object"
+                },
+                "examples": {
+                  "1": {
+                    "summary": "etcd Provider Example",
+                    "value": {
+                      "has_domain": false,
+                      "modifiedIndex": 728,
+                      "key": "/apisix/services/5",
+                      "createdIndex": 728,
+                      "value": {
+                        "create_time": 1634554563,
+                        "id": "5",
+                        "upstream": {
+                          "scheme": "http",
+                          "hash_on": "vars",
+                          "pass_host": "pass",
+                          "type": "roundrobin",
+                          "nodes": [
+                            {
+                              "port": 1980,
+                              "weight": 1,
+                              "host": "127.0.0.1"
+                            }
+                          ]
+                        },
+                        "update_time": 1634554563
+                      }
+                    }
+                  }
+                }
+              }
+            }
+          },
+          "404": {
+            "description": "Service not found",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "required": [
+                    "error_msg"
+                  ],
+                  "properties": {
+                    "error_msg": {
+                      "type": "string",
+                      "example": "service[2] not found"
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/v1/upstreams": {
+      "get": {
+        "summary": "Get All Upstreams",
+        "operationId": "listUpstreams",
+        "deprecated": false,
+        "description": "Get all configured upstreams.",
+        "tags": [
+          "Upstream"
+        ],
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": "OK",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "oneOf": [
+                    {
+                      "type": "array",
+                      "items": {
+                        "type": "object"
+                      }
+                    },
+                    {
+                      "type": "object",
+                      "maxProperties": 0,
+                      "description": "Empty collection before any matching 
runtime configuration exists."
+                    }
+                  ]
+                },
+                "examples": {
+                  "1": {
+                    "summary": "etcd Provider Example",
+                    "value": [
+                      {
+                        "value": {
+                          "scheme": "http",
+                          "pass_host": "pass",
+                          "nodes": [
+                            {
+                              "host": "127.0.0.1",
+                              "port": 80,
+                              "weight": 1
+                            },
+                            {
+                              "host": "foo.com",
+                              "port": 80,
+                              "weight": 2
+                            }
+                          ],
+                          "hash_on": "vars",
+                          "update_time": 1634543819,
+                          "key": "remote_addr",
+                          "create_time": 1634539759,
+                          "id": "1",
+                          "type": "chash"
+                        },
+                        "has_domain": true,
+                        "key": "/apisix/upstreams/1",
+                        "createdIndex": 938,
+                        "modifiedIndex": 1225
+                      }
+                    ]
+                  },
+                  "empty": {
+                    "summary": "Empty Collection",
+                    "value": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/v1/upstream/{upstream_id}": {
+      "get": {
+        "summary": "Get Upstream by ID",
+        "operationId": "getUpstream",
+        "deprecated": false,
+        "description": "Get an upstream by ID.",
+        "tags": [
+          "Upstream"
+        ],
+        "parameters": [
+          {
+            "name": "upstream_id",
+            "in": "path",
+            "description": "Upstream ID.",
+            "required": true,
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "OK",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object"
+                },
+                "examples": {
+                  "1": {
+                    "summary": "etcd Provider Example",
+                    "value": {
+                      "value": {
+                        "scheme": "http",
+                        "pass_host": "pass",
+                        "nodes": [
+                          {
+                            "host": "127.0.0.1",
+                            "port": 80,
+                            "weight": 1
+                          },
+                          {
+                            "host": "foo.com",
+                            "port": 80,
+                            "weight": 2
+                          }
+                        ],
+                        "hash_on": "vars",
+                        "update_time": 1634543819,
+                        "key": "remote_addr",
+                        "create_time": 1634539759,
+                        "id": "1",
+                        "type": "chash"
+                      },
+                      "has_domain": true,
+                      "key": "/apisix/upstreams/1",
+                      "createdIndex": 938,
+                      "modifiedIndex": 1225
+                    }
+                  }
+                }
+              }
+            }
+          },
+          "404": {
+            "description": "Upstream not found",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "required": [
+                    "error_msg"
+                  ],
+                  "properties": {
+                    "error_msg": {
+                      "type": "string",
+                      "example": "upstream[3] not found"
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/v1/plugin_metadatas": {
+      "get": {
+        "summary": "Get All Plugin Metadata",
+        "operationId": "listPluginMetadata",
+        "deprecated": false,
+        "description": "Get all plugin metadata.",
+        "tags": [
+          "Plugin Metadata"
+        ],
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": "OK",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "oneOf": [
+                    {
+                      "type": "array",
+                      "items": {
+                        "type": "object"
+                      }
+                    },
+                    {
+                      "type": "object",
+                      "maxProperties": 0,
+                      "description": "Empty collection before any matching 
runtime configuration exists."
+                    }
+                  ]
+                },
+                "examples": {
+                  "1": {
+                    "summary": "Successful Example",
+                    "value": [
+                      {
+                        "log_format": {
+                          "upstream_response_time": "$upstream_response_time"
+                        },
+                        "id": "file-logger"
+                      },
+                      {
+                        "ikey": 1,
+                        "skey": "val",
+                        "id": "example-plugin"
+                      }
+                    ]
+                  },
+                  "empty": {
+                    "summary": "Empty Collection",
+                    "value": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/v1/plugin_metadata/{plugin_name}": {
+      "get": {
+        "summary": "Get Plugin Metadata by Name",
+        "operationId": "getPluginMetadata",
+        "deprecated": false,
+        "description": "Get plugin metadata by the plugin name.",
+        "tags": [
+          "Plugin Metadata"
+        ],
+        "parameters": [
+          {
+            "name": "plugin_name",
+            "in": "path",
+            "description": "The specified plugin name.",
+            "required": true,
+            "example": "file-logger",
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "OK",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object"
+                },
+                "examples": {
+                  "1": {
+                    "summary": "Successful Example",
+                    "value": {
+                      "log_format": {
+                        "upstream_response_time": "$upstream_response_time"
+                      },
+                      "id": "file-logger"
+                    }
+                  }
+                }
+              }
+            }
+          },
+          "404": {
+            "description": "Plugin metadata not found",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "required": [
+                    "error_msg"
+                  ],
+                  "properties": {
+                    "error_msg": {
+                      "type": "string",
+                      "example": "plugin metadata[missing-plugin] not found"
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/v1/plugins/reload": {
+      "put": {
+        "summary": "Reload All Plugins",
+        "operationId": "reloadPlugins",
+        "deprecated": false,
+        "description": "Hot reload plugins for changes to the plugin list in 
[`conf/config.yaml`](https://github.com/apache/apisix/blob/master/conf/config.yaml.example)
 or plugin source files to take effect.",
+        "tags": [
+          "Plugin"
+        ],
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": "Plugins reloaded.",
+            "content": {
+              "text/plain": {
+                "schema": {
+                  "type": "string"
+                },
+                "example": "done"
+              }
+            }
+          },
+          "503": {
+            "description": "Reload state could not be recorded or the reload 
event could not be published.",
+            "content": {
+              "text/plain": {
+                "schema": {
+                  "type": "string",
+                  "description": "On reload-state recording failure, the body 
is a JSON-encoded error object. On event-publication failure, it is the raw 
event-system error string."
+                },
+                "examples": {
+                  "recordFailure": {
+                    "summary": "Reload State Recording Failure",
+                    "value": "{\"error_msg\":\"failed to record plugins 
reload\"}"
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/v1/discovery/{service}/dump": {
+      "get": {
+        "summary": "Get Memory Dump",
+        "operationId": "getServiceDiscoveryDump",
+        "deprecated": false,
+        "description": "Get module-defined in-memory data for a configured 
service discovery module. Response shape varies by module; the example shows 
Consul's `config` and `services` object. This endpoint exists only when the 
module implements runtime data dumping.",
+        "tags": [
+          "Service Discovery"
+        ],
+        "parameters": [
+          {
+            "name": "service",
+            "in": "path",
+            "required": true,
+            "description": "Configured service discovery module name.",
+            "schema": {
+              "type": "string"
+            },
+            "example": "consul"
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "OK",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object"
+                },
+                "examples": {
+                  "1": {
+                    "summary": "Consul Example",
+                    "value": {
+                      "services": {
+                        "svc-a": [
+                          {
+                            "host": "192.168.42.145",
+                            "weight": 1,
+                            "port": 9081
+                          },
+                          {
+                            "host": "192.168.42.145",
+                            "weight": 1,
+                            "port": 9082
+                          }
+                        ]
+                      },
+                      "config": {
+                        "dump": {
+                          "load_on_init": true,
+                          "expire": 2592000,
+                          "path": "logs/consul.dump"
+                        },
+                        "timeout": {
+                          "wait": 60,
+                          "read": 2000,
+                          "connect": 2000
+                        },
+                        "fetch_interval": 3,
+                        "keepalive": true,
+                        "weight": 1,
+                        "servers": [
+                          "http://consul:8500";
+                        ],
+                        "token": ""
+                      }
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/v1/discovery/{service}/show_dump_file": {
+      "get": {
+        "summary": "Get Dump File",
+        "operationId": "getServiceDiscoveryDumpFile",
+        "deprecated": false,
+        "description": "Read the persisted service discovery dump. This 
endpoint is currently provided by compatible service discovery modules such as 
Consul and exists only when the module is configured.",
+        "tags": [
+          "Service Discovery"
+        ],
+        "parameters": [
+          {
+            "name": "service",
+            "in": "path",
+            "required": true,
+            "description": "Configured service discovery module name.",
+            "schema": {
+              "type": "string"
+            },
+            "example": "consul"
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "OK",
+            "content": {
+              "text/plain": {
+                "schema": {
+                  "type": "string",
+                  "description": "Persisted service discovery dump encoded as 
JSON text."
+                },
+                "example": 
"{\"last_update\":1703594980,\"services\":{\"svc-a\":[{\"host\":\"192.168.42.145\",\"weight\":1,\"port\":9081},{\"host\":\"192.168.42.145\",\"weight\":1,\"port\":9082}]},\"expire\":2592000}"
+              }
+            }
+          },
+          "503": {
+            "description": "The dump file is not configured or could not be 
read.",
+            "content": {
+              "text/plain": {
+                "schema": {
+                  "type": "string"
+                },
+                "example": "dump params is nil"
+              }
+            }
+          }
+        }
+      }
+    },
+    "/v1/server_info": {
+      "get": {
+        "summary": "Get Server Information",
+        "operationId": "getServerInfo",
+        "description": "Get information about the local APISIX instance. This 
endpoint is available only when the `server-info` plugin is enabled. The plugin 
is deprecated in APISIX 3.13.0.",
+        "tags": [
+          "Server Information"
+        ],
+        "responses": {
+          "200": {
+            "description": "OK",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "required": [
+                    "boot_time",
+                    "hostname",
+                    "id",
+                    "version",
+                    "etcd_version"
+                  ],
+                  "properties": {
+                    "boot_time": {
+                      "type": "integer",
+                      "description": "Instance boot time as a Unix timestamp."
+                    },
+                    "hostname": {
+                      "type": "string",
+                      "description": "Instance hostname."
+                    },
+                    "id": {
+                      "oneOf": [
+                        {
+                          "type": "string"
+                        },
+                        {
+                          "type": "integer"
+                        }
+                      ],
+                      "description": "Configured APISIX instance ID."
+                    },
+                    "version": {
+                      "type": "string",
+                      "description": "APISIX version."
+                    },
+                    "etcd_version": {
+                      "type": "string",
+                      "description": "etcd version, or `unknown` when 
unavailable."
+                    }
+                  }
+                },
+                "example": {
+                  "boot_time": 1608522102,
+                  "hostname": "gateway-1",
+                  "id": "b7ce1c5c-b1aa-4df7-888a-cbe403f3e948",
+                  "version": "3.19.0",
+                  "etcd_version": "3.5.0"
+                }
+              }
+            }
+          },
+          "500": {
+            "description": "Server information could not be read."
+          }
+        }
+      }
+    }
+  },
+  "components": {
+    "schemas": {
+      "HealthChecker": {
+        "type": "object",
+        "properties": {
+          "name": {
+            "type": "string",
+            "description": "Resource or plugin target path identifying this 
checker."
+          },
+          "type": {
+            "type": "string",
+            "description": "Configured active check type, or passive check 
type when no active type is set."
+          },
+          "plugin": {
+            "type": "string",
+            "description": "Owning plugin name, present for plugin-owned 
checkers."
+          },
+          "meta": {
+            "type": "object",
+            "additionalProperties": true,
+            "description": "Optional plugin-defined target metadata; its keys 
depend on the plugin."
+          },
+          "nodes": {
+            "type": "array",
+            "description": "Registered targets. An empty target list is `[]`, 
not an object.",
+            "items": {
+              "type": "object",
+              "additionalProperties": true
+            }
+          }
+        }
+      }
+    }
+  },
+  "servers": [
+    {
+      "url": "http://127.0.0.1:9090";,
+      "description": "Local APISIX instance"
+    }
+  ]
+}
diff --git a/t/openapi/test_control_validator.py 
b/t/openapi/test_control_validator.py
new file mode 100644
index 0000000000..a893775d37
--- /dev/null
+++ b/t/openapi/test_control_validator.py
@@ -0,0 +1,189 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Regression checks for the offline Control API contract validator."""
+from copy import deepcopy
+import importlib.util
+import json
+from pathlib import Path
+import tempfile
+import unittest
+
+ROOT = Path(__file__).resolve().parents[2]
+MODULE = importlib.util.spec_from_file_location(
+    'control_contract', ROOT / 'utils/validate-control-api.py')
+validator = importlib.util.module_from_spec(MODULE)
+MODULE.loader.exec_module(validator)
+
+
+class ControlContractValidationTest(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        cls.document = json.loads(
+            (ROOT / 'docs/en/latest/control-api.json').read_text())
+
+    def test_contract(self):
+        self.assertEqual([], validator.validate(self.document, ROOT))
+
+    def test_missing_core_operation(self):
+        document = deepcopy(self.document)
+        del document['paths']['/v1/schema']
+        self.assertTrue(validator.validate_source(document, ROOT))
+
+    def test_unregistered_operation(self):
+        document = deepcopy(self.document)
+        document['paths']['/v1/not_registered'] = {
+            'get': {
+                'operationId': 'getUnregistered',
+                'responses': {'200': {'description': 'OK'}},
+            }
+        }
+        self.assertTrue(validator.validate_source(document, ROOT))
+
+    def test_downstream_reference(self):
+        document = deepcopy(self.document)
+        document['info']['description'] += ' https://docs.api7.ai/apisix'
+        self.assertTrue(validator.validate_source(document, ROOT))
+
+    def test_conditional_endpoint_must_be_qualified(self):
+        document = deepcopy(self.document)
+        document['paths']['/v1/server_info']['get']['description'] = (
+            'Get information about the local APISIX instance.')
+        self.assertTrue(validator.validate_source(document, ROOT))
+
+    def test_control_api_security_boundary(self):
+        document = deepcopy(self.document)
+        document['security'] = [{'ApiKey': []}]
+        document['servers'][0]['url'] = 'http://0.0.0.0:9090'
+        errors = validator.validate_source(document, ROOT)
+        self.assertGreaterEqual(len(errors), 2)
+
+    def test_version_and_openapi_validation(self):
+        document = deepcopy(self.document)
+        document['info']['version'] = '3.18.0'
+        self.assertTrue(validator.validate(document, ROOT))
+
+    def 
test_core_parser_accepts_multiple_methods_and_rejects_unknown_shape(self):
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            source = root / 'apisix/control/v1.lua'
+            source.parent.mkdir(parents=True)
+            source.write_text(
+                'return { { methods = {"GET", "HEAD"}, '
+                'uris = {"/new_multi"}, handler = handler } }')
+            self.assertEqual(
+                {('/v1/new_multi', 'get'), ('/v1/new_multi', 'head')},
+                validator.core_operations(root),
+            )
+            source.write_text(
+                'return { { methods = allowed_methods, '
+                'uris = {"/unknown"}, handler = handler } }')
+            with self.assertRaises(ValueError):
+                validator.core_operations(root)
+
+    def test_source_backed_response_invariants(self):
+        mutations = []
+
+        document = deepcopy(self.document)
+        document['paths']['/v1/route/{route_id}']['get']['responses']['200'][
+            'content']['application/json']['examples']['1']['value'] = {'id': 
'1'}
+        mutations.append(document)
+
+        document = deepcopy(self.document)
+        response = document['paths'][
+            
'/v1/discovery/{service}/show_dump_file']['get']['responses']['200']
+        response['content'] = {'application/json': {'schema': {'type': 
'object'}}}
+        mutations.append(document)
+
+        document = deepcopy(self.document)
+        document['paths']['/v1/server_info']['get']['description'] = (
+            document['paths']['/v1/server_info']['get']['description']
+            .replace('3.13.0', '3.17.0'))
+        mutations.append(document)
+
+        document = deepcopy(self.document)
+        document['paths']['/v1/plugins/reload']['put']['responses']['503'][
+            'content'] = {'application/json': {'schema': {'type': 'object'}}}
+        mutations.append(document)
+
+        document = deepcopy(self.document)
+        document['paths']['/v1/route/{route_id}']['get']['responses']['404'][
+            
'content']['application/json']['schema']['properties']['error_msg'][
+                'example'] = 'Resource not found.'
+        mutations.append(document)
+
+        document = deepcopy(self.document)
+        document['paths']['/v1/routes']['get']['responses']['200'][
+            'content']['application/json']['schema'] = {'type': 'array'}
+        mutations.append(document)
+
+        document = deepcopy(self.document)
+        document['paths']['/v1/server_info']['get']['responses']['200'][
+            'content']['application/json']['schema']['properties']['id'] = {
+                'type': 'string'}
+        mutations.append(document)
+
+        document = deepcopy(self.document)
+        schema_example = document['paths']['/v1/schema']['get']['responses'][
+            
'200']['content']['application/json']['examples']['successful']['value']
+        schema_example['stream-plugins'] = schema_example.pop('stream_plugins')
+        mutations.append(document)
+
+        document = deepcopy(self.document)
+        plugin_schema = document['paths']['/v1/schema']['get']['responses'][
+            
'200']['content']['application/json']['examples']['successful']['value'][
+                'plugins']['example-plugin']['schema']
+        del plugin_schema['properties']['i']
+        mutations.append(document)
+
+        document = deepcopy(self.document)
+        mqtt_proxy = document['paths']['/v1/schema']['get']['responses'][
+            
'200']['content']['application/json']['examples']['successful']['value'][
+                'stream_plugins']['mqtt-proxy']
+        del mqtt_proxy['version']
+        mutations.append(document)
+
+        document = deepcopy(self.document)
+        document['paths']['/v1/schema']['get']['responses']['200']['content'][
+            'application/json']['examples']['successful']['value']['main'][
+                'route']['properties'] = {}
+        mutations.append(document)
+
+        document = deepcopy(self.document)
+        document['paths']['/v1/healthcheck']['get']['responses']['503'][
+            
'content']['application/json']['schema']['properties']['error_msg'] = {
+                'type': 'string', 'example': 'HTML template rendering failed.'}
+        mutations.append(document)
+
+        document = deepcopy(self.document)
+        document['paths']['/v1/plugins/reload']['put']['responses']['503'][
+            'content']['text/plain']['examples']['recordFailure']['value'] = (
+                'failed to reload plugins')
+        mutations.append(document)
+
+        document = deepcopy(self.document)
+        health_example = 
document['paths']['/v1/healthcheck']['get']['responses'][
+            '200']['content']['application/json']['examples']['1']['value'][0]
+        del health_example['nodes'][0]['hostname']
+        del health_example['nodes'][1]['hostname']
+        mutations.append(document)
+
+        for document in mutations:
+            self.assertTrue(validator.validate_response_contract(document))
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/utils/validate-control-api.py b/utils/validate-control-api.py
new file mode 100644
index 0000000000..1bdee3ef08
--- /dev/null
+++ b/utils/validate-control-api.py
@@ -0,0 +1,315 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Validate the checked-in Control API contract against this checkout."""
+import argparse
+import importlib.util
+import json
+from pathlib import Path
+import re
+import sys
+
+ROOT = Path(__file__).resolve().parents[1]
+ADMIN_VALIDATOR_PATH = ROOT / 'utils/validate-admin-api.py'
+MODULE = importlib.util.spec_from_file_location('admin_contract', 
ADMIN_VALIDATOR_PATH)
+common = importlib.util.module_from_spec(MODULE)
+MODULE.loader.exec_module(common)
+
+METHODS = common.METHODS
+
+
+def operations(document):
+    return {(path, method) for path, item in document.get('paths', {}).items()
+            for method in METHODS & item.keys()}
+
+
+def core_operations(root):
+    """Extract the built-in v1 route table and expand its wildcard 
contracts."""
+    text = (root / 'apisix/control/v1.lua').read_text()
+    route_table = text.rsplit('return {', 1)[1]
+    routes = re.findall(
+        r'\{\s*methods\s*=\s*\{([^}]*)\}\s*,?\s*'
+        r'uris\s*=\s*\{([^}]*)\}',
+        route_table,
+        re.DOTALL,
+    )
+    method_entries = len(re.findall(r'\bmethods\s*=', route_table))
+    if len(routes) != method_entries:
+        raise ValueError(
+            f'could not parse {method_entries - len(routes)} Control API route 
entries'
+        )
+    expanded = set()
+    wildcard_paths = {
+        '/healthcheck/*': (
+            '/v1/healthcheck/{src_type}/{src_id}',
+            '/v1/healthcheck/{src_type}/{src_id}/checkers',
+        ),
+        '/route/*': ('/v1/route/{route_id}',),
+        '/service/*': ('/v1/service/{service_id}',),
+        '/upstream/*': ('/v1/upstream/{upstream_id}',),
+        '/plugin_metadata/*': ('/v1/plugin_metadata/{plugin_name}',),
+    }
+    for methods_source, paths_source in routes:
+        methods = re.findall(r'"([A-Z]+)"', methods_source)
+        paths = re.findall(r'"(/[^"]+)"', paths_source)
+        if not methods or not paths:
+            raise ValueError('Control API route entry has no parseable method 
or URI')
+        for method in methods:
+            for path in paths:
+                for contract_path in wildcard_paths.get(path, ('/v1' + path,)):
+                    expanded.add((contract_path, method.lower()))
+    return expanded
+
+
+def optional_operations(root):
+    """Return stable patterns registered by shipped optional components."""
+    result = set()
+    server_info = (root / 'apisix/plugins/server-info.lua').read_text()
+    if 'function _M.control_api()' in server_info and '"/v1/server_info"' in 
server_info:
+        result.add(('/v1/server_info', 'get'))
+
+    discovery_sources = list((root / 'apisix/discovery').glob('*/init.lua'))
+    if any('function _M.dump_data()' in source.read_text() for source in 
discovery_sources):
+        result.add(('/v1/discovery/{service}/dump', 'get'))
+    if any('function _M.control_api()' in source.read_text() and
+           '"/show_dump_file"' in source.read_text() for source in 
discovery_sources):
+        result.add(('/v1/discovery/{service}/show_dump_file', 'get'))
+    return result
+
+
+def validate_response_contract(document):
+    """Protect source-verified response details that generic OAS lint cannot 
infer."""
+    errors = []
+
+    def get(*parts):
+        value = document
+        for part in parts:
+            if not isinstance(value, dict) or part not in value:
+                return None
+            value = value[part]
+        return value
+
+    schema_example = get('paths', '/v1/schema', 'get', 'responses', '200',
+                         'content', 'application/json', 'examples', 
'successful',
+                         'value') or {}
+    schema_example_description = get(
+        'paths', '/v1/schema', 'get', 'responses', '200', 'content',
+        'application/json', 'examples', 'successful', 'description') or ''
+    if 'Representative fields' not in schema_example_description:
+        errors.append('schema example must identify its abbreviated main 
schemas')
+    for resource in ('route', 'upstream'):
+        properties = schema_example.get('main', {}).get(resource, 
{}).get('properties', {})
+        if not properties:
+            errors.append(f'schema example main.{resource} must show 
representative fields')
+    if 'stream_plugins' not in schema_example or 'stream-plugins' in 
schema_example:
+        errors.append('schema example must use the runtime stream_plugins 
response key')
+    example_plugin = (schema_example.get('plugins', {})
+                      .get('example-plugin', {}))
+    if set(example_plugin) != {'version', 'priority', 'schema', 
'metadata_schema'}:
+        errors.append('schema example must match example-plugin source fields')
+    plugin_schema = example_plugin.get('schema', {})
+    plugin_properties = plugin_schema.get('properties', {})
+    if (set(plugin_properties) != {'_meta', 'i', 's', 't', 'ip', 'port'} or
+            plugin_schema.get('required') != ['i'] or
+            plugin_schema.get('$comment') !=
+            'this is a mark for our injected plugin schema'):
+        errors.append('schema example must include the loaded example-plugin 
schema')
+    plugin_meta = plugin_properties.get('_meta', {})
+    if (plugin_meta.get('additionalProperties') is not False or
+            set(plugin_meta.get('properties', {})) != {
+                'disable', 'error_response', 'priority', 'filter', 
'pre_function'}):
+        errors.append('schema example must include the injected plugin _meta 
schema')
+    metadata_schema = example_plugin.get('metadata_schema', {})
+    if (set(metadata_schema.get('properties', {})) != {'ikey', 'skey'} or
+            metadata_schema.get('required') != ['ikey', 'skey']):
+        errors.append('schema example must include example-plugin metadata 
fields')
+    mqtt_proxy = schema_example.get('stream_plugins', {}).get('mqtt-proxy', {})
+    if set(mqtt_proxy) != {'version', 'schema', 'priority'} or 
mqtt_proxy.get('version') != 0.1:
+        errors.append('schema example must match mqtt-proxy source fields')
+    mqtt_schema = mqtt_proxy.get('schema', {})
+    mqtt_properties = mqtt_schema.get('properties', {})
+    if (set(mqtt_properties) != {'_meta', 'protocol_name', 'protocol_level'} or
+            mqtt_schema.get('required') != ['protocol_level'] or
+            mqtt_schema.get('$comment') !=
+            'this is a mark for our injected plugin schema'):
+        errors.append('schema example must include the loaded mqtt-proxy 
schema')
+
+    for path in ('/v1/healthcheck',
+                 '/v1/healthcheck/{src_type}/{src_id}',
+                 '/v1/healthcheck/{src_type}/{src_id}/checkers'):
+        error_schema = get('paths', path, 'get', 'responses', '503', 'content',
+                           'application/json', 'schema', 'properties', 
'error_msg') or {}
+        if error_schema.get('pattern') != r'^HTML template rendering: .+$':
+            errors.append(f'GET {path}: HTML error must preserve the runtime 
prefix')
+
+    for path in ('/v1/route/{route_id}', '/v1/upstream/{upstream_id}'):
+        example = get('paths', path, 'get', 'responses', '200', 'content',
+                      'application/json', 'examples', '1', 'value')
+        if not isinstance(example, dict) or not 
isinstance(example.get('value'), dict):
+            errors.append(f'GET {path}: example must preserve the config-entry 
value envelope')
+
+    for path in ('/v1/routes', '/v1/services', '/v1/upstreams',
+                 '/v1/plugin_metadatas'):
+        schema = get('paths', path, 'get', 'responses', '200', 'content',
+                     'application/json', 'schema') or {}
+        choices = schema.get('oneOf', []) if isinstance(schema, dict) else []
+        types = {choice.get('type') for choice in choices if 
isinstance(choice, dict)}
+        empty_object = any(choice.get('type') == 'object' and
+                           choice.get('maxProperties') == 0 for choice in 
choices
+                           if isinstance(choice, dict))
+        if types != {'array', 'object'} or not empty_object:
+            errors.append(f'GET {path}: schema must include the runtime 
empty-object response')
+
+    dump_content = get('paths', '/v1/discovery/{service}/show_dump_file',
+                       'get', 'responses', '200', 'content')
+    if not isinstance(dump_content, dict) or set(dump_content) != 
{'text/plain'}:
+        errors.append('discovery dump-file response must be JSON text with 
text/plain media type')
+
+    server_text = ' '.join([
+        str(get('paths', '/v1/server_info', 'get', 'description') or ''),
+        *(str(tag.get('description', '')) for tag in document.get('tags', [])
+          if tag.get('name') == 'Server Information'),
+    ])
+    if 'deprecated in APISIX 3.13.0' not in server_text or '3.17.0' in 
server_text:
+        errors.append('server-info deprecation must identify APISIX 3.13.0')
+
+    plugin_parameters = get('paths', '/v1/plugin_metadata/{plugin_name}',
+                            'get', 'parameters') or []
+    plugin_example = next((parameter.get('example') for parameter in 
plugin_parameters
+                           if parameter.get('name') == 'plugin_name'), None)
+    if plugin_example != 'file-logger':
+        errors.append('plugin_name must use a non-empty source-backed example')
+
+    server_id = get('paths', '/v1/server_info', 'get', 'responses', '200',
+                    'content', 'application/json', 'schema', 'properties', 
'id') or {}
+    id_choices = server_id.get('oneOf', []) if isinstance(server_id, dict) 
else []
+    if {choice.get('type') for choice in id_choices if isinstance(choice, 
dict)} != {
+            'string', 'integer'}:
+        errors.append('server_info.id must allow generated strings and 
configured integers')
+
+    reload_response = get('paths', '/v1/plugins/reload', 'put', 'responses', 
'503') or {}
+    reload_content = reload_response.get('content', {})
+    reload_description = reload_response.get('description', '').lower()
+    if (set(reload_content) != {'text/plain'} or
+            'recorded' not in reload_description or 'published' not in 
reload_description):
+        errors.append('plugin reload 503 must cover both failure paths as 
text/plain')
+    reload_media = reload_content.get('text/plain', {})
+    record_example = (reload_media.get('examples', {})
+                      .get('recordFailure', {}).get('value'))
+    if record_example != '{"error_msg":"failed to record plugins reload"}':
+        errors.append('plugin reload 503 must preserve the exact 
recording-failure body')
+    reload_schema_description = reload_media.get('schema', 
{}).get('description', '')
+    if 'raw event-system error string' not in reload_schema_description:
+        errors.append('plugin reload 503 must distinguish the raw publication 
error body')
+
+    dump_summary = get('paths', '/v1/discovery/{service}/dump', 'get', 
'responses',
+                       '200', 'content', 'application/json', 'examples', '1', 
'summary')
+    if dump_summary != 'Consul Example':
+        errors.append('service discovery dump example must identify its Consul 
shape')
+    dump_description = get('paths', '/v1/discovery/{service}/dump', 'get',
+                           'description') or ''
+    if 'Response shape varies by module' not in dump_description or 'Consul' 
not in dump_description:
+        errors.append('service discovery dump must qualify module-specific 
response shapes')
+
+    for path in ('/v1/healthcheck', '/v1/healthcheck/{src_type}/{src_id}'):
+        examples = get('paths', path, 'get', 'responses', '200', 'content',
+                       'application/json', 'examples') or {}
+        if '"hostname"' not in json.dumps(examples):
+            errors.append(f'GET {path}: checker node examples must include 
hostname')
+
+    expected_errors = {
+        ('/v1/healthcheck/{src_type}/{src_id}', '400'): 'invalid src type 
route',
+        ('/v1/healthcheck/{src_type}/{src_id}', '404'): 'routes[1] not found',
+        ('/v1/healthcheck/{src_type}/{src_id}/checkers', '400'): 'invalid src 
type route',
+        ('/v1/healthcheck/{src_type}/{src_id}/checkers', '404'): 'routes[1] 
not found',
+        ('/v1/route/{route_id}', '404'): 'route[2] not found',
+        ('/v1/service/{service_id}', '404'): 'service[2] not found',
+        ('/v1/upstream/{upstream_id}', '404'): 'upstream[3] not found',
+        ('/v1/plugin_metadata/{plugin_name}', '404'):
+            'plugin metadata[missing-plugin] not found',
+    }
+    for (path, status), expected in expected_errors.items():
+        actual = get('paths', path, 'get', 'responses', status, 'content',
+                     'application/json', 'schema', 'properties', 'error_msg', 
'example')
+        if actual != expected:
+            errors.append(f'GET {path} {status}: error_msg example must be 
{expected!r}')
+    return errors
+
+
+def validate_source(document, root):
+    errors = []
+    expected = core_operations(root) | optional_operations(root)
+    actual = operations(document)
+    if actual != expected:
+        errors.append(
+            f'operation surface differs: missing {sorted(expected - actual)}, '
+            f'extra {sorted(actual - expected)}'
+        )
+
+    description = document.get('info', {}).get('description', '')
+    if 'Enabled plugins and service discovery modules can register additional 
endpoints' not in description:
+        errors.append('info.description must distinguish 
configuration-dependent endpoints')
+    if re.search(r'api7|apiseven', json.dumps(document), re.IGNORECASE):
+        errors.append('Control API specification must not contain 
API7-specific references')
+    if document.get('security') != []:
+        errors.append('Control API must explicitly declare that it has no 
authentication')
+    servers = document.get('servers', [])
+    if len(servers) != 1 or servers[0].get('url') != 'http://127.0.0.1:9090':
+        errors.append('Control API must document only the default loopback 
server')
+
+    conditional = {
+        '/v1/server_info': ('only when', '`server-info` plugin'),
+        '/v1/discovery/{service}/dump': ('only when', 'service discovery 
module'),
+        '/v1/discovery/{service}/show_dump_file': ('only when', 'service 
discovery module'),
+    }
+    for path, phrases in conditional.items():
+        operation = document.get('paths', {}).get(path, {}).get('get', {})
+        text = operation.get('description', '')
+        for phrase in phrases:
+            if phrase not in text:
+                errors.append(f'GET {path}: description must include 
{phrase!r}')
+    errors.extend(validate_response_contract(document))
+    return errors
+
+
+def validate(document, root=ROOT):
+    errors = common.validate(document, common.repository_versions(root))
+    errors.extend(validate_source(document, root))
+    return errors
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        'spec',
+        nargs='?',
+        type=Path,
+        default=ROOT / 'docs/en/latest/control-api.json',
+    )
+    args = parser.parse_args()
+    document = json.loads(args.spec.read_text())
+    errors = validate(document)
+    if errors:
+        print('\n'.join(f'- {error}' for error in errors), file=sys.stderr)
+        return 1
+    operation_count = len(operations(document))
+    schema_count = len(document.get('components', {}).get('schemas', {}))
+    print(f'Control API contract is valid: {operation_count} operations, 
{schema_count} schemas')
+    return 0
+
+
+if __name__ == '__main__':
+    raise SystemExit(main())

Reply via email to