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())