This is an automated email from the ASF dual-hosted git repository.
AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git
The following commit(s) were added to refs/heads/master by this push:
new 026c0dc5f feat: add oas-validator plugin (#13344)
026c0dc5f is described below
commit 026c0dc5f5ef587ebcc8d6d97ee37ff1df0ab103
Author: AlinsRan <[email protected]>
AuthorDate: Mon May 11 16:27:51 2026 +0800
feat: add oas-validator plugin (#13344)
---
apisix-master-0.rockspec | 1 +
apisix/cli/config.lua | 1 +
apisix/plugins/oas-validator.lua | 299 ++++++++++
conf/config.yaml.example | 1 +
docs/en/latest/config.json | 1 +
docs/en/latest/plugins/oas-validator.md | 168 ++++++
docs/zh/latest/config.json | 1 +
docs/zh/latest/plugins/oas-validator.md | 168 ++++++
t/admin/plugins.t | 1 +
t/plugin/oas-validator.t | 838 ++++++++++++++++++++++++++++
t/plugin/oas-validator2.t | 956 ++++++++++++++++++++++++++++++++
t/plugin/oas-validator3.t | 564 +++++++++++++++++++
t/spec/spec.json | 834 ++++++++++++++++++++++++++++
t/spec/spec31-gaps.json | 193 +++++++
t/spec/spec31.json | 300 ++++++++++
15 files changed, 4326 insertions(+)
diff --git a/apisix-master-0.rockspec b/apisix-master-0.rockspec
index e92266db4..c9d9c73be 100644
--- a/apisix-master-0.rockspec
+++ b/apisix-master-0.rockspec
@@ -46,6 +46,7 @@ dependencies = {
"lua-resty-hmac-ffi = 0.06-1",
"lua-resty-cookie = 0.4.1-1",
"lua-resty-session = 4.1.5-1",
+ "lua-resty-openapi-validator = 1.0.5-1",
"opentracing-openresty = 0.1-0",
"lua-resty-radixtree = 2.9.2-0",
"lua-protobuf = 0.5.3-1",
diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua
index 956eef30c..2af80eb35 100644
--- a/apisix/cli/config.lua
+++ b/apisix/cli/config.lua
@@ -250,6 +250,7 @@ local _M = {
"traffic-split",
"redirect",
"response-rewrite",
+ "oas-validator",
"mcp-bridge",
"degraphql",
"kafka-proxy",
diff --git a/apisix/plugins/oas-validator.lua b/apisix/plugins/oas-validator.lua
new file mode 100644
index 000000000..0cdd5b4a0
--- /dev/null
+++ b/apisix/plugins/oas-validator.lua
@@ -0,0 +1,299 @@
+--
+-- 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.
+--
+
+local core = require("apisix.core")
+local secret = require("apisix.secret")
+local plugin = require("apisix.plugin")
+local ov = require("resty.openapi_validator")
+local http = require("resty.http")
+local ngx_req = ngx.req
+local ngx_md5 = ngx.md5
+local pairs = pairs
+local ipairs = ipairs
+local tostring = tostring
+local tab_sort = table.sort
+local tab_concat = table.concat
+
+local plugin_name = "oas-validator"
+
+local DEFAULT_SPEC_URL_TTL = 3600
+
+local schema = {
+ type = "object",
+ properties = {
+ spec = {
+ description = "schema against which the request/response will be
validated",
+ type = "string",
+ minLength = 1
+ },
+ spec_url = {
+ description = "URL to fetch the OpenAPI spec from",
+ type = "string",
+ pattern = [[^https?://]],
+ },
+ spec_url_request_headers = {
+ description = "custom HTTP headers to include when fetching
spec_url",
+ type = "object",
+ additionalProperties = {
+ type = "string",
+ },
+ },
+ ssl_verify = {
+ description = "whether to verify SSL certificate when fetching
spec_url",
+ type = "boolean",
+ default = false,
+ },
+ timeout = {
+ description = "HTTP request timeout in milliseconds for fetching
spec_url",
+ type = "integer",
+ minimum = 1000,
+ maximum = 60000,
+ default = 10000,
+ },
+ verbose_errors = {
+ type = "boolean",
+ default = false
+ },
+ skip_request_body_validation = {
+ type = "boolean",
+ default = false
+ },
+ skip_request_header_validation = {
+ type = "boolean",
+ default = false
+ },
+ skip_query_param_validation = {
+ type = "boolean",
+ default = false
+ },
+ skip_path_params_validation = {
+ type = "boolean",
+ default = false
+ },
+ reject_if_not_match = {
+ type = "boolean",
+ default = true
+ },
+ rejection_status_code = {
+ description = "HTTP status code to return when request validation
fails",
+ type = "integer",
+ minimum = 400,
+ maximum = 599,
+ default = 400
+ }
+ },
+ oneOf = {
+ {required = {"spec"}},
+ {required = {"spec_url"}},
+ },
+}
+
+local metadata_schema = {
+ type = "object",
+ properties = {
+ spec_url_ttl = {
+ description = "TTL in seconds for cached spec fetched from
spec_url",
+ type = "integer",
+ minimum = 1,
+ default = DEFAULT_SPEC_URL_TTL,
+ },
+ },
+}
+
+local spec_url_lrucache
+local spec_url_lrucache_ttl
+
+local function get_spec_url_ttl()
+ local metadata = plugin.plugin_metadata(plugin_name)
+ if metadata and metadata.value and metadata.value.spec_url_ttl then
+ return metadata.value.spec_url_ttl
+ end
+ return DEFAULT_SPEC_URL_TTL
+end
+
+local function get_spec_url_lrucache()
+ local ttl = get_spec_url_ttl()
+ if not spec_url_lrucache or spec_url_lrucache_ttl ~= ttl then
+ spec_url_lrucache = core.lrucache.new({
+ ttl = ttl,
+ count = 512,
+ invalid_stale = true,
+ refresh_stale = true,
+ serial_creating = true,
+ })
+ spec_url_lrucache_ttl = ttl
+ end
+ return spec_url_lrucache
+end
+
+local function fetch_and_compile(conf)
+ local httpc = http.new()
+ httpc:set_timeout(conf.timeout or 10000)
+
+ local params = {
+ method = "GET",
+ ssl_verify = conf.ssl_verify or false,
+ }
+ if conf.spec_url_request_headers then
+ params.headers = conf.spec_url_request_headers
+ end
+
+ local res, err = httpc:request_uri(conf.spec_url, params)
+ if not res then
+ return nil, "failed to fetch spec from URL: " .. err
+ end
+
+ if res.status ~= 200 then
+ return nil, "spec URL returned status " .. res.status
+ end
+
+ local validator, err = ov.compile(res.body)
+ if not validator then
+ return nil, "failed to compile openapi spec fetched from URL: " .. err
+ end
+
+ return validator
+end
+
+local _M = {
+ version = 0.1,
+ priority = 512,
+ name = plugin_name,
+ schema = schema,
+ metadata_schema = metadata_schema,
+}
+
+
+function _M.check_schema(conf, schema_type)
+ if schema_type == core.schema.TYPE_METADATA then
+ return core.schema.check(metadata_schema, conf)
+ end
+
+ local ok, err = core.schema.check(schema, conf)
+ if not ok then
+ return false, err
+ end
+
+ if conf.spec and not secret.is_secret_ref(conf.spec) then
+ local _, decode_err = core.json.decode(conf.spec)
+ if decode_err then
+ return false, "invalid JSON string provided, err: " .. decode_err
+ end
+ end
+
+ return true
+end
+
+
+local function get_validator(conf)
+ if conf.spec then
+ conf._meta = conf._meta or {}
+ if not conf._meta.validator then
+ local validator, err = ov.compile(conf.spec)
+ if not validator then
+ return nil, "failed to compile openapi spec, err: " .. err
+ end
+ conf._meta.validator = validator
+ end
+ return conf._meta.validator
+ end
+
+ local lrucache = get_spec_url_lrucache()
+ local ssl_verify = conf.ssl_verify or false
+ local cache_key = conf.spec_url .. "#ssl_verify=" .. tostring(ssl_verify)
+ if conf.spec_url_request_headers then
+ local sorted_keys = {}
+ for k in pairs(conf.spec_url_request_headers) do
+ sorted_keys[#sorted_keys + 1] = k
+ end
+ tab_sort(sorted_keys)
+ local parts = {}
+ for _, k in ipairs(sorted_keys) do
+ parts[#parts + 1] = k .. "=" .. conf.spec_url_request_headers[k]
+ end
+ cache_key = cache_key .. "#" .. ngx_md5(tab_concat(parts, "&"))
+ end
+ local validator, err = lrucache(cache_key, nil, fetch_and_compile, conf)
+ if not validator then
+ return nil, err
+ end
+ return validator
+end
+
+
+function _M.access(conf, ctx)
+ local validator, err = get_validator(conf)
+ if not validator then
+ core.log.error(err)
+ return 500, {message = "failed to parse openapi spec"}
+ end
+
+ local req_body
+ if not conf.skip_request_body_validation then
+ local body, body_err = core.request.get_body()
+ if body_err ~= nil then
+ core.log.error("failed reading request body, err: " .. body_err)
+ return 500, {message = "error reading the request body. err: " ..
body_err}
+ end
+ req_body = body
+ end
+
+ local headers
+ if not conf.skip_request_header_validation then
+ local h, h_err = ngx_req.get_headers(0, true)
+ if h_err ~= nil then
+ core.log.error("failed reading request headers, err: " .. h_err)
+ return 500, {message = "error reading the request headers, err: "
.. h_err}
+ end
+ headers = h
+ end
+
+ local query
+ if not conf.skip_query_param_validation then
+ query = core.request.get_uri_args(ctx)
+ end
+
+ local ok, validate_err = validator:validate_request({
+ method = core.request.get_method(),
+ path = ctx.var.uri,
+ query = query,
+ headers = headers,
+ body = req_body,
+ content_type = ctx.var.content_type,
+ }, {
+ path = conf.skip_path_params_validation,
+ query = conf.skip_query_param_validation,
+ header = conf.skip_request_header_validation,
+ body = conf.skip_request_body_validation,
+ })
+
+ if not ok then
+ core.log.error("error occurred while validating request [" ..
+ core.request.get_method() .. " " .. ctx.var.uri,
+ "], err: " .. validate_err)
+
+ if conf.reject_if_not_match then
+ if not conf.verbose_errors then
+ validate_err = ""
+ end
+ return conf.rejection_status_code,
+ {message = "failed to validate request. " .. validate_err}
+ end
+ end
+end
+
+return _M
diff --git a/conf/config.yaml.example b/conf/config.yaml.example
index ae7155a86..0e068d542 100644
--- a/conf/config.yaml.example
+++ b/conf/config.yaml.example
@@ -533,6 +533,7 @@ plugins: # plugin list (sorted by
priority)
- traffic-split # priority: 966
- redirect # priority: 900
- response-rewrite # priority: 899
+ - oas-validator # priority: 512
- mcp-bridge # priority: 510
- degraphql # priority: 509
- kafka-proxy # priority: 508
diff --git a/docs/en/latest/config.json b/docs/en/latest/config.json
index d24eacc3f..e5459e5bd 100644
--- a/docs/en/latest/config.json
+++ b/docs/en/latest/config.json
@@ -162,6 +162,7 @@
"plugins/limit-count",
"plugins/proxy-cache",
"plugins/request-validation",
+ "plugins/oas-validator",
"plugins/proxy-mirror",
"plugins/api-breaker",
"plugins/traffic-split",
diff --git a/docs/en/latest/plugins/oas-validator.md
b/docs/en/latest/plugins/oas-validator.md
new file mode 100644
index 000000000..a96fe2c3e
--- /dev/null
+++ b/docs/en/latest/plugins/oas-validator.md
@@ -0,0 +1,168 @@
+---
+title: oas-validator
+keywords:
+ - Apache APISIX
+ - API Gateway
+ - Plugin
+ - oas-validator
+ - OpenAPI
+ - request validation
+description: The oas-validator Plugin validates incoming HTTP requests against
an OpenAPI Specification (OAS) 3.x document, rejecting non-conforming requests
before they reach the upstream service.
+---
+
+<!--
+#
+# 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.
+#
+-->
+
+<head>
+ <link rel="canonical" href="https://docs.api7.ai/hub/oas-validator" />
+</head>
+
+## Description
+
+The `oas-validator` Plugin validates incoming HTTP requests against an
[OpenAPI Specification (OAS) 3.x](https://swagger.io/specification/) document
before forwarding them to the upstream service. It can validate the request
method, path, query parameters, request headers, and body.
+
+The OpenAPI spec can be provided as an inline JSON string or fetched from a
remote URL with configurable caching. Validation failures return a configurable
HTTP error status, and detailed error messages can optionally be included in
the response body.
+
+## Attributes
+
+| Name | Type | Required | Default | Valid values | Description |
+|------|------|----------|---------|--------------|-------------|
+| spec | string | No* | | | Inline OpenAPI 3.x specification in JSON format.
Required if `spec_url` is not set. |
+| spec_url | string | No* | | `^https?://` | URL to fetch the OpenAPI
specification from. Required if `spec` is not set. |
+| spec_url_request_headers | object | No | | | Custom HTTP request headers
sent when fetching `spec_url`. Useful for authenticated specification
endpoints. |
+| ssl_verify | boolean | No | false | | Whether to verify the TLS certificate
when fetching `spec_url`. |
+| timeout | integer | No | 10000 | [1000, 60000] | HTTP request timeout in
milliseconds for fetching `spec_url`. |
+| verbose_errors | boolean | No | false | | When `true`, include detailed
validation error messages in the response body. |
+| skip_request_body_validation | boolean | No | false | | Skip validation of
the request body. |
+| skip_request_header_validation | boolean | No | false | | Skip validation of
request headers. |
+| skip_query_param_validation | boolean | No | false | | Skip validation of
query string parameters. |
+| skip_path_params_validation | boolean | No | false | | Skip validation of
path parameters. |
+| reject_if_not_match | boolean | No | true | | When `true`, reject requests
that fail validation. When `false`, log the validation failure and allow the
request through. |
+| rejection_status_code | integer | No | 400 | [400, 599] | HTTP status code
to return when a request fails validation. |
+
+\* Exactly one of `spec` or `spec_url` must be provided.
+
+### Plugin Metadata
+
+The following metadata attributes control behavior at the plugin level and are
configured through the Plugin Metadata API:
+
+| Name | Type | Required | Default | Valid values | Description |
+|------|------|----------|---------|--------------|-------------|
+| spec_url_ttl | integer | No | 3600 | ≥ 1 | Time in seconds to cache a
specification fetched from `spec_url`. |
+
+## Examples
+
+The examples below demonstrate how you can configure `oas-validator` in
different scenarios.
+
+:::note
+
+```bash
+admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed
's/"//g')
+```
+
+:::
+
+### Validate Requests with an Inline Specification
+
+The following example demonstrates how to validate requests against an inline
OpenAPI 3.x specification. Requests that do not conform to the spec are
rejected with a `400` response.
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
+ -H "X-API-KEY: ${admin_key}" \
+ -d '{
+ "id": "oas-validator-route",
+ "uri": "/api/v3/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "{\"openapi\":\"3.0.2\",\"info\":{\"title\":\"Pet
API\",\"version\":\"1.0.0\"},\"paths\":{\"/api/v3/pet\":{\"post\":{\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"required\":[\"name\"],\"properties\":{\"name\":{\"type\":\"string\"},\"status\":{\"type\":\"string\"}}}}}},\"responses\":{\"200\":{\"description\":\"OK\"}}}}}}",
+ "verbose_errors": true
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {"httpbin.org:80": 1}
+ }
+ }'
+```
+
+Send a valid request with the required `name` field:
+
+```shell
+curl -i "http://127.0.0.1:9080/api/v3/pet" -X POST \
+ -H "Content-Type: application/json" \
+ -d '{"name": "doggie", "status": "available"}'
+```
+
+You should receive a `200` response from the upstream.
+
+Send an invalid request without the required `name` field:
+
+```shell
+curl -i "http://127.0.0.1:9080/api/v3/pet" -X POST \
+ -H "Content-Type: application/json" \
+ -d '{"status": "available"}'
+```
+
+You should receive a `400` response with a validation error message.
+
+### Validate Requests with a Remote Specification URL
+
+The following example demonstrates how to fetch the OpenAPI specification from
a remote URL. The spec is fetched once and cached for the duration specified by
`spec_url_ttl` in the plugin metadata.
+
+Configure the plugin metadata to set the cache TTL for the remote spec:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/plugin_metadata/oas-validator" -X PUT
\
+ -H "X-API-KEY: ${admin_key}" \
+ -d '{
+ "spec_url_ttl": 600
+ }'
+```
+
+Create a Route with the `oas-validator` Plugin:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
+ -H "X-API-KEY: ${admin_key}" \
+ -d '{
+ "id": "oas-validator-url-route",
+ "uri": "/api/v3/*",
+ "plugins": {
+ "oas-validator": {
+ "spec_url": "https://petstore3.swagger.io/api/v3/openapi.json",
+ "ssl_verify": false,
+ "verbose_errors": true
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {"httpbin.org:80": 1}
+ }
+ }'
+```
+
+Send a request that does not conform to the Petstore spec:
+
+```shell
+curl -i "http://127.0.0.1:9080/api/v3/pet" -X POST \
+ -H "Content-Type: application/json" \
+ -d '{"invalid": "body"}'
+```
+
+You should receive a `400` response with a detailed validation error message
because `verbose_errors` is set to `true`.
diff --git a/docs/zh/latest/config.json b/docs/zh/latest/config.json
index b765065d3..947339973 100644
--- a/docs/zh/latest/config.json
+++ b/docs/zh/latest/config.json
@@ -151,6 +151,7 @@
"plugins/limit-count",
"plugins/proxy-cache",
"plugins/request-validation",
+ "plugins/oas-validator",
"plugins/proxy-mirror",
"plugins/api-breaker",
"plugins/traffic-split",
diff --git a/docs/zh/latest/plugins/oas-validator.md
b/docs/zh/latest/plugins/oas-validator.md
new file mode 100644
index 000000000..0dc3e9585
--- /dev/null
+++ b/docs/zh/latest/plugins/oas-validator.md
@@ -0,0 +1,168 @@
+---
+title: oas-validator
+keywords:
+ - Apache APISIX
+ - API 网关
+ - Plugin
+ - oas-validator
+ - OpenAPI
+ - 请求校验
+description: oas-validator 插件根据 OpenAPI Specification(OAS)3.x 文档校验入站 HTTP
请求,在请求到达上游服务前拒绝不合规的请求。
+---
+
+<!--
+#
+# 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.
+#
+-->
+
+<head>
+ <link rel="canonical" href="https://docs.api7.ai/hub/oas-validator" />
+</head>
+
+## 描述
+
+`oas-validator` 插件在请求转发至上游服务之前,根据 [OpenAPI
Specification(OAS)3.x](https://swagger.io/specification/) 文档对入站 HTTP
请求进行校验。可校验内容包括请求方法、路径、查询参数、请求头以及请求体。
+
+OpenAPI 规范可以以内联 JSON 字符串的形式提供,也可以从远程 URL 获取并配置缓存。校验失败时返回可配置的 HTTP
错误状态码,并可选择在响应体中包含详细的错误信息。
+
+## 属性
+
+| 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述 |
+|------|------|--------|--------|--------|------|
+| spec | string | 否* | | | 内联 OpenAPI 3.x 规范(JSON 格式)。未设置 `spec_url` 时必填。 |
+| spec_url | string | 否* | | `^https?://` | 获取 OpenAPI 规范的 URL。未设置 `spec` 时必填。
|
+| spec_url_request_headers | object | 否 | | | 获取 `spec_url` 时附带的自定义 HTTP
请求头,适用于需要鉴权的规范接口。 |
+| ssl_verify | boolean | 否 | false | | 获取 `spec_url` 时是否校验 TLS 证书。 |
+| timeout | integer | 否 | 10000 | [1000, 60000] | 获取 `spec_url` 的 HTTP
请求超时时间(毫秒)。 |
+| verbose_errors | boolean | 否 | false | | 为 `true` 时,在响应体中返回详细的校验错误信息。 |
+| skip_request_body_validation | boolean | 否 | false | | 跳过请求体校验。 |
+| skip_request_header_validation | boolean | 否 | false | | 跳过请求头校验。 |
+| skip_query_param_validation | boolean | 否 | false | | 跳过查询参数校验。 |
+| skip_path_params_validation | boolean | 否 | false | | 跳过路径参数校验。 |
+| reject_if_not_match | boolean | 否 | true | | 为 `true` 时,拒绝校验失败的请求;为 `false`
时,仅记录校验失败日志并放行请求。 |
+| rejection_status_code | integer | 否 | 400 | [400, 599] | 请求校验失败时返回的 HTTP
状态码。 |
+
+\* `spec` 与 `spec_url` 必须且只能设置其中一个。
+
+### 插件元数据
+
+以下元数据属性通过插件元数据 API 进行配置,作用于插件级别:
+
+| 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述 |
+|------|------|--------|--------|--------|------|
+| spec_url_ttl | integer | 否 | 3600 | ≥ 1 | 从 `spec_url` 获取的规范的缓存时间(秒)。 |
+
+## 示例
+
+以下示例演示了如何在不同场景中使用 `oas-validator` 插件。
+
+:::note
+
+```bash
+admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed
's/"//g')
+```
+
+:::
+
+### 使用内联规范校验请求
+
+以下示例演示如何使用内联 OpenAPI 3.x 规范校验请求。不符合规范的请求将以 `400` 响应被拒绝。
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
+ -H "X-API-KEY: ${admin_key}" \
+ -d '{
+ "id": "oas-validator-route",
+ "uri": "/api/v3/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "{\"openapi\":\"3.0.2\",\"info\":{\"title\":\"Pet
API\",\"version\":\"1.0.0\"},\"paths\":{\"/api/v3/pet\":{\"post\":{\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"required\":[\"name\"],\"properties\":{\"name\":{\"type\":\"string\"},\"status\":{\"type\":\"string\"}}}}}},\"responses\":{\"200\":{\"description\":\"OK\"}}}}}}",
+ "verbose_errors": true
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {"httpbin.org:80": 1}
+ }
+ }'
+```
+
+发送一个包含必填 `name` 字段的合法请求:
+
+```shell
+curl -i "http://127.0.0.1:9080/api/v3/pet" -X POST \
+ -H "Content-Type: application/json" \
+ -d '{"name": "doggie", "status": "available"}'
+```
+
+将收到来自上游的 `200` 响应。
+
+发送一个缺少必填 `name` 字段的非法请求:
+
+```shell
+curl -i "http://127.0.0.1:9080/api/v3/pet" -X POST \
+ -H "Content-Type: application/json" \
+ -d '{"status": "available"}'
+```
+
+将收到包含校验错误信息的 `400` 响应。
+
+### 使用远程规范 URL 校验请求
+
+以下示例演示如何从远程 URL 获取 OpenAPI 规范。规范在首次获取后会被缓存,缓存时长由插件元数据的 `spec_url_ttl` 参数决定。
+
+配置插件元数据以设置远程规范的缓存时间:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/plugin_metadata/oas-validator" -X PUT
\
+ -H "X-API-KEY: ${admin_key}" \
+ -d '{
+ "spec_url_ttl": 600
+ }'
+```
+
+创建带有 `oas-validator` 插件的路由:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
+ -H "X-API-KEY: ${admin_key}" \
+ -d '{
+ "id": "oas-validator-url-route",
+ "uri": "/api/v3/*",
+ "plugins": {
+ "oas-validator": {
+ "spec_url": "https://petstore3.swagger.io/api/v3/openapi.json",
+ "ssl_verify": false,
+ "verbose_errors": true
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {"httpbin.org:80": 1}
+ }
+ }'
+```
+
+发送一个不符合 Petstore 规范的请求:
+
+```shell
+curl -i "http://127.0.0.1:9080/api/v3/pet" -X POST \
+ -H "Content-Type: application/json" \
+ -d '{"invalid": "body"}'
+```
+
+由于 `verbose_errors` 设置为 `true`,将收到包含详细校验错误信息的 `400` 响应。
diff --git a/t/admin/plugins.t b/t/admin/plugins.t
index adb98b28b..f45256f17 100644
--- a/t/admin/plugins.t
+++ b/t/admin/plugins.t
@@ -115,6 +115,7 @@ gzip
traffic-split
redirect
response-rewrite
+oas-validator
mcp-bridge
degraphql
kafka-proxy
diff --git a/t/plugin/oas-validator.t b/t/plugin/oas-validator.t
new file mode 100644
index 000000000..11539a3f3
--- /dev/null
+++ b/t/plugin/oas-validator.t
@@ -0,0 +1,838 @@
+#
+# 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.
+#
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+no_long_string();
+no_shuffle();
+no_root_location();
+
+add_block_preprocessor(sub {
+ my ($block) = @_;
+
+ my $http_config = $block->http_config // <<_EOC_;
+ # fake upstream server for pass-through validation tests
+ server {
+ listen 1971;
+ location / {
+ content_by_lua_block {
+ ngx.status = 200
+ ngx.say("ok")
+ }
+ }
+ }
+_EOC_
+
+ $block->set_value("http_config", $http_config);
+
+ if (!$block->request) {
+ $block->set_value("request", "GET /t");
+ }
+
+ if (!$block->error_log && !$block->no_error_log) {
+ $block->set_value("no_error_log", "[error]\n[alert]");
+ }
+});
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: sanity
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local plugin = require("apisix.plugins.oas-validator")
+ local ospec = t.read_file("t/spec/spec.json")
+
+ local ok, err = plugin.check_schema({spec = ospec})
+ if not ok then
+ ngx.say(err)
+ end
+
+ ngx.say("done")
+ }
+ }
+--- response_body
+done
+
+
+
+=== TEST 2: open api string should be json
+--- config
+ location /t {
+ content_by_lua_block {
+ local plugin = require("apisix.plugins.oas-validator")
+ local ok, err = plugin.check_schema({spec = "invalid json string"})
+ ngx.say(err)
+ }
+ }
+--- response_body
+invalid JSON string provided, err: Expected value but found invalid token at
character 1
+
+
+
+=== TEST 3: create route correctly
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec.json")
+ spec = spec:gsub('\"', '\\"')
+
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s"
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1971": 1
+ }
+ }
+ }]], spec)
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 4: test body validation -- POST
+--- request
+POST /api/v3/pet
+{"id": 10, "name": "doggie", "category": {"id": 1, "name": "Dogs"},
"photoUrls": ["string"], "tags": [{"id": 1, "name": "tag1"}], "status":
"available"}
+--- more_headers
+Content-Type: application/json
+--- no_error_log
+[error]
+
+
+
+=== TEST 5: test body validation -- PUT
+--- request
+PUT /api/v3/pet
+{"id": 10, "name": "doggie", "category": { "id": 1, "name": "Dogs"},
"photoUrls": [ "string"], "tags": [{ "id": 0, "name": "string"}], "status":
"available"}
+--- more_headers
+Content-Type: application/json
+--- no_error_log
+[error]
+
+
+
+=== TEST 6: passing incorrect body should fail
+--- request
+POST /api/v3/pet
+{"lol": "watdis?"}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 7: test body validation with Query Params
+--- request
+GET /api/v3/pet/findByStatus?status=pending
+--- more_headers
+Content-Type: application/json
+--- no_error_log
+[error]
+
+
+
+=== TEST 8: querying for married dogs should fail (incorrect query param)
+--- request
+GET /api/v3/pet/findByStatus?status=married
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 9: test body validation with Path Params
+--- request
+GET /api/v3/pet/10
+--- more_headers
+Content-Type: application/json
+--- no_error_log
+[error]
+
+
+
+=== TEST 10: querying with wrong path uri param should fail
+--- request
+GET /api/v3/pet/wrong-id
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 11: create route for skipping body validation
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec.json")
+ spec = spec:gsub('\"', '\\"')
+
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s",
+ "skip_request_body_validation": true
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1971": 1
+ }
+ }
+ }]], spec)
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 12: passing incorrect body should pass validation
(skip_request_body_validation = true)
+--- request
+POST /api/v3/pet
+{"lol": "watdis?"}
+--- more_headers
+Content-Type: application/json
+--- no_error_log
+[error]
+
+
+
+=== TEST 13: create route for skipping header validation
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec.json")
+ spec = spec:gsub('\"', '\\"')
+
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s",
+ "skip_request_header_validation": true
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1971": 1
+ }
+ }
+ }]], spec)
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 14: passing incorrect header should pass validation
(skip_request_header_validation = true)
+--- request
+GET /api/v3/pet/1
+--- more_headers
+Content-Type: not-application/json
+--- error_code: 200
+
+
+
+=== TEST 15: create route for skipping query param validation
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec.json")
+ spec = spec:gsub('\"', '\\"')
+
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s",
+ "skip_query_param_validation": true
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1971": 1
+ }
+ }
+ }]], spec)
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 16: querying for incorrect query params should pass
(skip_query_param_validation = true)
+--- request
+GET /api/v3/pet/findByStatus?status=married
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+
+
+
+=== TEST 17: create route for skipping path param validation
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec.json")
+ spec = spec:gsub('\"', '\\"')
+
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s",
+ "skip_path_params_validation": true
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1971": 1
+ }
+ }
+ }]], spec)
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 18: querying for incorrect path params should pass
(skip_path_params_validation = true)
+--- request
+GET /api/v3/pet/incorrect-id
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+
+
+
+=== Test 19: test multipleOf validation
+--- request
+POST /api/v3/multipleoftest
+{"testnumber": 1.13}
+--- more_headers
+Content-Type: application/json
+--- no_error_log
+[error]
+
+
+
+=== Test 20: test multipleOf validation - invalid
+--- request
+POST /api/v3/multipleoftest
+{"testnumber": 1.1312}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 21: route setup with reject_if_not_match = false
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec.json")
+ spec = spec:gsub('\"', '\\"')
+
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s",
+ "reject_if_not_match": false
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1980": 1
+ }
+ }
+ }]], spec)
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 22: invalid body should still pass to upstream (reject_if_not_match
is false)
+--- upstream_server_config
+ location /api/v3/pet {
+ content_by_lua_block {
+ ngx.log(ngx.WARN, "upstream reached")
+ ngx.status = 200
+ ngx.say("ok")
+ }
+ }
+--- request
+POST /api/v3/pet
+{"lol": "watdis?"}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- error_log
+error occurred while validating request
+--- grep_error_log eval
+qr/upstream reached/
+--- grep_error_log_out
+upstream reached
+
+
+
+=== TEST 23: create route with explicit reject_if_not_match = true
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec.json")
+ spec = spec:gsub('\"', '\\"')
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s",
+ "reject_if_not_match": true
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1971": 1
+ }
+ }
+ }]], spec)
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 24: invalid body should be rejected when reject_if_not_match is true
+--- request
+POST /api/v3/pet
+{"lol": "watdis?"}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 25: create route with rejection_status_code = 422
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec.json")
+ spec = spec:gsub('\"', '\\"')
+
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s",
+ "reject_if_not_match": true,
+ "rejection_status_code": 422
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1971": 1
+ }
+ }
+ }]], spec)
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 26: invalid body should return 422 when rejection_status_code = 422
+--- request
+POST /api/v3/pet
+{"lol": "watdis?"}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 422
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 27: create route with rejection_status_code = 503
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec.json")
+ spec = spec:gsub('\"', '\\"')
+
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s",
+ "reject_if_not_match": true,
+ "rejection_status_code": 503
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1971": 1
+ }
+ }
+ }]], spec)
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 28: invalid body should return 503 when rejection_status_code = 503
+--- request
+POST /api/v3/pet
+{"lol": "watdis?"}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 503
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 29: schema should reject rejection_status_code = 399 (out of range)
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec.json")
+ spec = spec:gsub('\"', '\\"')
+
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s",
+ "rejection_status_code": 399
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1971": 1
+ }
+ }
+ }]], spec)
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- error_code: 400
+--- response_body_like: validation failed
+
+
+
+=== TEST 30: schema should reject rejection_status_code = 600 (out of range)
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec.json")
+ spec = spec:gsub('\"', '\\"')
+
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s",
+ "rejection_status_code": 600
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1971": 1
+ }
+ }
+ }]], spec)
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- error_code: 400
+--- response_body_like: validation failed
+
+
+
+=== TEST 31: boundary value rejection_status_code = 400 should be accepted
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec.json")
+ spec = spec:gsub('\"', '\\"')
+
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s",
+ "rejection_status_code": 400
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1971": 1
+ }
+ }
+ }]], spec)
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 32: boundary value rejection_status_code = 599 should be accepted
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec.json")
+ spec = spec:gsub('\"', '\\"')
+
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s",
+ "rejection_status_code": 599
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1971": 1
+ }
+ }
+ }]], spec)
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 33: rejection_status_code should not affect behavior when
reject_if_not_match = false
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec.json")
+ spec = spec:gsub('\"', '\\"')
+
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s",
+ "reject_if_not_match": false,
+ "rejection_status_code": 422
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1980": 1
+ }
+ }
+ }]], spec)
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 34: invalid body should pass to upstream when reject_if_not_match =
false (rejection_status_code ignored)
+--- upstream_server_config
+ location /api/v3/pet {
+ content_by_lua_block {
+ ngx.log(ngx.WARN, "upstream reached")
+ ngx.status = 200
+ ngx.say("ok")
+ }
+ }
+--- request
+POST /api/v3/pet
+{"lol": "watdis?"}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- error_log
+error occurred while validating request
+--- grep_error_log eval
+qr/upstream reached/
+--- grep_error_log_out
+upstream reached
diff --git a/t/plugin/oas-validator2.t b/t/plugin/oas-validator2.t
new file mode 100644
index 000000000..f3bc151d4
--- /dev/null
+++ b/t/plugin/oas-validator2.t
@@ -0,0 +1,956 @@
+#
+# 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.
+#
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+no_long_string();
+no_shuffle();
+no_root_location();
+
+add_block_preprocessor(sub {
+ my ($block) = @_;
+
+ my $http_config = $block->http_config // <<_EOC_;
+ # fake server, only for test
+ server {
+ listen 1970;
+ location / {
+ content_by_lua_block {
+ ngx.say("ok")
+ }
+ }
+ }
+_EOC_
+
+ $block->set_value("http_config", $http_config);
+
+ if (!$block->request) {
+ $block->set_value("request", "GET /t");
+ }
+
+ if (!$block->error_log && !$block->no_error_log) {
+ $block->set_value("no_error_log", "[error]\n[alert]");
+ }
+});
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: OAS 3.1 -- create route with spec31.json
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec31.json")
+ spec = spec:gsub('\"', '\\"')
+
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s"
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1970": 1
+ }
+ }
+ }]], spec)
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 2: OAS 3.1 exclusiveMinimum/Maximum (numeric) -- value within range
should pass
+--- request
+POST /api/v31/exclusive
+{"score": 50}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- no_error_log
+[error]
+
+
+
+=== TEST 3: OAS 3.1 exclusiveMinimum -- value equal to lower bound (0) should
fail
+--- request
+POST /api/v31/exclusive
+{"score": 0}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 4: OAS 3.1 exclusiveMaximum -- value equal to upper bound (100)
should fail
+--- request
+POST /api/v31/exclusive
+{"score": 100}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 5: OAS 3.1 if/then/else -- circle with radius should pass (then
branch)
+--- request
+POST /api/v31/shape
+{"type": "circle", "radius": 5}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- no_error_log
+[error]
+
+
+
+=== TEST 6: OAS 3.1 if/then -- circle without radius should fail
+--- request
+POST /api/v31/shape
+{"type": "circle"}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 7: OAS 3.1 if/else -- rectangle with width and height should pass
(else branch)
+--- request
+POST /api/v31/shape
+{"type": "rectangle", "width": 10, "height": 5}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- no_error_log
+[error]
+
+
+
+=== TEST 8: OAS 3.1 if/else -- rectangle missing width should fail
+--- request
+POST /api/v31/shape
+{"type": "rectangle", "height": 5}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 9: OAS 3.1 anyOf -- matching first subschema should pass
+--- request
+POST /api/v31/anyof
+{"name": "doggie"}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- no_error_log
+[error]
+
+
+
+=== TEST 10: OAS 3.1 anyOf -- matching second subschema should pass
+--- request
+POST /api/v31/anyof
+{"id": 42}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- no_error_log
+[error]
+
+
+
+=== TEST 11: OAS 3.1 anyOf -- matching neither subschema should fail
+--- request
+POST /api/v31/anyof
+{"other": "value"}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 12: OAS 3.1 oneOf -- matching exactly one subschema should pass
+--- request
+POST /api/v31/oneof
+{"cat": "whiskers"}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- no_error_log
+[error]
+
+
+
+=== TEST 13: OAS 3.1 oneOf -- matching both subschemas should fail
+--- request
+POST /api/v31/oneof
+{"cat": "whiskers", "dog": "rex"}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 14: OAS 3.1 allOf -- all subschemas satisfied should pass
+--- request
+POST /api/v31/allof
+{"a": "hello", "b": 42}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- no_error_log
+[error]
+
+
+
+=== TEST 15: OAS 3.1 allOf -- missing field required by one subschema should
fail
+--- request
+POST /api/v31/allof
+{"a": "hello"}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 16: OAS 3.1 -- route with spec31.json and reject_if_not_match = false
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec31.json")
+ spec = spec:gsub('\"', '\\"')
+
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s",
+ "reject_if_not_match": false
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1980": 1
+ }
+ }
+ }]], spec)
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 17: OAS 3.1 reject_if_not_match = false -- invalid body passes
through to upstream
+--- upstream_server_config
+ location /api/v31/pet {
+ content_by_lua_block {
+ ngx.log(ngx.WARN, "upstream reached")
+ ngx.status = 200
+ ngx.say("ok")
+ }
+ }
+--- request
+POST /api/v31/pet
+{"lol": "watdis?"}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- error_log
+error occurred while validating request
+--- grep_error_log eval
+qr/upstream reached/
+--- grep_error_log_out
+upstream reached
+
+
+
+=== TEST 18: OAS 3.1 -- route with spec31.json and rejection_status_code = 422
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec31.json")
+ spec = spec:gsub('\"', '\\"')
+
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s",
+ "reject_if_not_match": true,
+ "rejection_status_code": 422
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1970": 1
+ }
+ }
+ }]], spec)
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 19: OAS 3.1 rejection_status_code = 422 -- invalid body returns 422
+--- request
+POST /api/v31/pet
+{"lol": "watdis?"}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 422
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 20: OAS 3.1 -- route with spec31.json and verbose_errors = true
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec31.json")
+ spec = spec:gsub('\"', '\\"')
+
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s",
+ "verbose_errors": true
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1970": 1
+ }
+ }
+ }]], spec)
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 21: OAS 3.1 verbose_errors = true -- error response body contains
schema detail
+--- request
+POST /api/v31/pet
+{"lol": "watdis?"}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request\..+
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 22: OAS 3.1 -- route with spec31.json and
skip_request_body_validation = true
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec31.json")
+ spec = spec:gsub('\"', '\\"')
+
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s",
+ "skip_request_body_validation": true
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1970": 1
+ }
+ }
+ }]], spec)
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 23: OAS 3.1 skip_request_body_validation = true -- invalid body is
not rejected
+--- request
+POST /api/v31/pet
+{"lol": "watdis?"}
+--- more_headers
+Content-Type: application/json
+--- no_error_log
+[error]
+
+
+
+=== TEST 24: OAS 3.1 -- route with spec31.json and skip_query_param_validation
= true
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec31.json")
+ spec = spec:gsub('\"', '\\"')
+
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s",
+ "skip_query_param_validation": true
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1970": 1
+ }
+ }
+ }]], spec)
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 25: OAS 3.1 skip_query_param_validation = true -- invalid enum query
param is not rejected
+--- request
+GET /api/v31/pet/findByStatus?status=married
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+
+
+
+=== TEST 26: OAS 3.1 -- route with spec31.json and skip_path_params_validation
= true
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec31.json")
+ spec = spec:gsub('\"', '\\"')
+
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s",
+ "skip_path_params_validation": true
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1970": 1
+ }
+ }
+ }]], spec)
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 27: OAS 3.1 skip_path_params_validation = true -- non-integer path
param is not rejected
+--- request
+GET /api/v31/pet/not-an-id
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+
+
+
+=== TEST 28: OAS 3.1 -- restore route with spec31.json (no extra options)
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec31.json")
+ spec = spec:gsub('\"', '\\"')
+
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s"
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1970": 1
+ }
+ }
+ }]], spec)
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 29: OAS 3.1 body validation -- valid Pet passes plugin (upstream
returns 404)
+--- request
+POST /api/v31/pet
+{"name": "doggie", "photoUrls": ["http://example.com/img.jpg"]}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- no_error_log
+[error]
+
+
+
+=== TEST 30: OAS 3.1 body validation -- missing required field should fail
+--- request
+POST /api/v31/pet
+{"name": "doggie"}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 31: OAS 3.1 path param validation -- valid integer id passes plugin
+--- request
+GET /api/v31/pet/42
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- no_error_log
+[error]
+
+
+
+=== TEST 32: OAS 3.1 path param validation -- non-integer id should fail
+--- request
+GET /api/v31/pet/not-an-id
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 33: OAS 3.1 query param validation -- valid enum value passes plugin
+--- request
+GET /api/v31/pet/findByStatus?status=available
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- no_error_log
+[error]
+
+
+
+=== TEST 34: OAS 3.1 query param validation -- invalid enum value should fail
+--- request
+GET /api/v31/pet/findByStatus?status=married
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 35: OAS 3.1 nullable type array -- null value should pass
+--- request
+POST /api/v31/nullable
+{"value": null}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- no_error_log
+[error]
+
+
+
+=== TEST 36: OAS 3.1 nullable type array -- string value should pass
+--- request
+POST /api/v31/nullable
+{"value": "hello"}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- no_error_log
+[error]
+
+
+
+=== TEST 37: OAS 3.1 nullable type array -- integer value should fail
+--- request
+POST /api/v31/nullable
+{"value": 123}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 38: OAS 3.1 const keyword -- correct value should pass
+--- request
+POST /api/v31/const
+{"version": "v1"}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- no_error_log
+[error]
+
+
+
+=== TEST 39: OAS 3.1 const keyword -- wrong value should fail
+--- request
+POST /api/v31/const
+{"version": "v2"}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 40: OAS 3.1 multipleOf validation -- valid value passes
+--- request
+POST /api/v31/multipleoftest
+{"testnumber": 1.13}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- no_error_log
+[error]
+
+
+
+=== TEST 41: OAS 3.1 multipleOf validation -- invalid value should fail
+--- request
+POST /api/v31/multipleoftest
+{"testnumber": 1.1312}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 42: OAS 3.1 -- create route with spec31-gaps.json
(components/pathItems, not, patternProperties, $dynamicRef)
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local spec = t.read_file("t/spec/spec31-gaps.json")
+ spec = spec:gsub('\"', '\\"')
+
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ string.format([[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec": "%s"
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1970": 1
+ }
+ }
+ }]], spec)
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 43: components/pathItems -- valid body via $ref path should pass
+--- request
+POST /api/v31gap/widget
+{"name": "foo"}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- no_error_log
+[error]
+
+
+
+=== TEST 44: components/pathItems -- invalid body via $ref path should fail
+--- request
+POST /api/v31gap/widget
+{"notaname": "foo"}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 45: not keyword -- value satisfying not constraint should pass
+--- request
+POST /api/v31gap/item
+{"value": "not-an-integer"}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- no_error_log
+[error]
+
+
+
+=== TEST 46: not keyword -- value violating not constraint should fail
+--- request
+POST /api/v31gap/item
+{"value": 42}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 47: patternProperties -- matching key with correct type should pass
+--- request
+POST /api/v31gap/pattern
+{"S_name": "hello"}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- no_error_log
+[error]
+
+
+
+=== TEST 48: patternProperties -- matching key with wrong type should fail
+--- request
+POST /api/v31gap/pattern
+{"S_name": 123}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 49: patternProperties with additionalProperties:false -- non-matching
key should be rejected
+--- request
+POST /api/v31gap/pattern
+{"S_name": "hello", "extra": "not_allowed"}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 50: $dynamicRef -- array with correct element type should pass
+--- request
+POST /api/v31gap/dynref
+{"items": ["hello", "world"]}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- no_error_log
+[error]
+
+
+
+=== TEST 51: $dynamicRef -- array with wrong element type should fail
+--- request
+POST /api/v31gap/dynref
+{"items": [1, 2, 3]}
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 52: [LIMITATION] contentMediaType/contentEncoding are annotations
only -- non-JSON string passes without content validation
+--- request
+POST /api/v31gap/content-annotation
+{"data": "this is NOT valid base64 nor JSON"}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- no_error_log
+[error]
+
+
+
+=== TEST 53: prefixItems -- correct positional types should pass
+--- request
+POST /api/v31gap/prefixitems
+["hello", 42, true]
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- no_error_log
+[error]
+
+
+
+=== TEST 54: prefixItems -- wrong type at first position should fail
+--- request
+POST /api/v31gap/prefixitems
+[123, 42, true]
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 55: [LIMITATION] prefixItems + items -- extra items beyond
prefixItems are validated by items schema
+--- request
+POST /api/v31gap/prefixitems
+["hello", 42, "not_a_boolean"]
+--- more_headers
+Content-Type: application/json
+--- response_body_like: failed to validate request.
+--- error_code: 400
+--- error_log
+error occurred while validating request
diff --git a/t/plugin/oas-validator3.t b/t/plugin/oas-validator3.t
new file mode 100644
index 000000000..cdd2b9010
--- /dev/null
+++ b/t/plugin/oas-validator3.t
@@ -0,0 +1,564 @@
+#
+# 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.
+#
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+no_long_string();
+no_shuffle();
+no_root_location();
+
+add_block_preprocessor(sub {
+ my ($block) = @_;
+
+ my $http_config = $block->http_config // <<_EOC_;
+ server {
+ listen 1979;
+ location /spec.json {
+ content_by_lua_block {
+ local file = io.open("t/spec/spec.json", "r")
+ local content = file:read("*a")
+ file:close()
+ ngx.print(content)
+ }
+ }
+ location /invalid.json {
+ content_by_lua_block {
+ ngx.print("not valid json {{{")
+ }
+ }
+ location /not-found.json {
+ content_by_lua_block {
+ ngx.status = 404
+ ngx.print("not found")
+ }
+ }
+ location /spec-with-auth.json {
+ content_by_lua_block {
+ local headers = ngx.req.get_headers()
+ if headers["X-Token"] ~= "my-secret-token" then
+ ngx.status = 403
+ ngx.print("forbidden")
+ return
+ end
+ local file = io.open("t/spec/spec.json", "r")
+ local content = file:read("*a")
+ file:close()
+ ngx.print(content)
+ }
+ }
+ }
+
+ server {
+ listen 1970;
+ location / {
+ content_by_lua_block {
+ ngx.say("ok")
+ }
+ }
+ }
+_EOC_
+
+ $block->set_value("http_config", $http_config);
+
+ if (!$block->request) {
+ $block->set_value("request", "GET /t");
+ }
+
+ if (!$block->error_log && !$block->no_error_log) {
+ $block->set_value("no_error_log", "[error]\n[alert]");
+ }
+});
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: schema validation -- spec_url is accepted
+--- config
+ location /t {
+ content_by_lua_block {
+ local plugin = require("apisix.plugins.oas-validator")
+ local ok, err = plugin.check_schema({
+ spec_url = "http://127.0.0.1:1979/spec.json"
+ })
+ if not ok then
+ ngx.say(err)
+ return
+ end
+ ngx.say("done")
+ }
+ }
+--- response_body
+done
+
+
+
+=== TEST 2: schema validation -- spec and spec_url are mutually exclusive
+--- config
+ location /t {
+ content_by_lua_block {
+ local plugin = require("apisix.plugins.oas-validator")
+ local ok, err = plugin.check_schema({
+ spec = "{}",
+ spec_url = "http://127.0.0.1:1979/spec.json"
+ })
+ if not ok then
+ ngx.say("rejected")
+ return
+ end
+ ngx.say("ok")
+ }
+ }
+--- response_body
+rejected
+
+
+
+=== TEST 3: schema validation -- neither spec nor spec_url fails
+--- config
+ location /t {
+ content_by_lua_block {
+ local plugin = require("apisix.plugins.oas-validator")
+ local ok, err = plugin.check_schema({
+ verbose_errors = true
+ })
+ if not ok then
+ ngx.say("rejected")
+ return
+ end
+ ngx.say("ok")
+ }
+ }
+--- response_body
+rejected
+
+
+
+=== TEST 4: schema validation -- spec_url must be http/https
+--- config
+ location /t {
+ content_by_lua_block {
+ local plugin = require("apisix.plugins.oas-validator")
+ local ok, err = plugin.check_schema({
+ spec_url = "ftp://example.com/spec.json"
+ })
+ if not ok then
+ ngx.say("rejected")
+ return
+ end
+ ngx.say("ok")
+ }
+ }
+--- response_body
+rejected
+
+
+
+=== TEST 5: create route with spec_url
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec_url": "http://127.0.0.1:1979/spec.json"
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1970": 1
+ }
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 6: request validation works with spec_url
+--- request
+POST /api/v3/pet
+{"id": 10, "name": "doggie", "category": {"id": 1, "name": "Dogs"},
"photoUrls": ["string"], "tags": [{"id": 1, "name": "tag1"}], "status":
"available"}
+--- more_headers
+Content-Type: application/json
+--- no_error_log
+[error]
+
+
+
+=== TEST 7: invalid request body fails validation with spec_url
+--- request
+POST /api/v3/pet
+{"invalid": "body"}
+--- more_headers
+Content-Type: application/json
+--- error_code: 400
+--- response_body_like: failed to validate request
+--- error_log
+error occurred while validating request
+
+
+
+=== TEST 8: spec_url returning non-200 triggers error
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec_url": "http://127.0.0.1:1979/not-found.json"
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1970": 1
+ }
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 9: request to route with non-200 spec_url returns 500
+--- request
+GET /api/v3/pet/1
+--- error_code: 500
+--- response_body_like: failed to parse openapi spec
+--- error_log
+spec URL returned status 404
+
+
+
+=== TEST 10: spec_url returning invalid JSON triggers error
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec_url": "http://127.0.0.1:1979/invalid.json"
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1970": 1
+ }
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 11: request to route with invalid JSON spec_url returns 500
+--- request
+GET /api/v3/pet/1
+--- error_code: 500
+--- response_body_like: failed to parse openapi spec
+--- error_log
+failed to compile openapi spec fetched from URL
+
+
+
+=== TEST 12: spec_url with custom request headers
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec_url":
"http://127.0.0.1:1979/spec-with-auth.json",
+ "spec_url_request_headers": {
+ "X-Token": "my-secret-token"
+ }
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1970": 1
+ }
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 13: request validation works with custom headers spec_url
+--- request
+POST /api/v3/pet
+{"id": 10, "name": "doggie", "category": {"id": 1, "name": "Dogs"},
"photoUrls": ["string"], "tags": [{"id": 1, "name": "tag1"}], "status":
"available"}
+--- more_headers
+Content-Type: application/json
+--- no_error_log
+[error]
+
+
+
+=== TEST 14: spec_url without required auth header fails
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec_url":
"http://127.0.0.1:1979/spec-with-auth.json"
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1970": 1
+ }
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 15: request to route with missing auth returns 500
+--- request
+GET /api/v3/pet/1
+--- error_code: 500
+--- response_body_like: failed to parse openapi spec
+--- error_log
+spec URL returned status 403
+
+
+
+=== TEST 16: metadata schema validation
+--- config
+ location /t {
+ content_by_lua_block {
+ local plugin = require("apisix.plugins.oas-validator")
+ local core = require("apisix.core")
+ local ok, err = plugin.check_schema({spec_url_ttl = 60},
core.schema.TYPE_METADATA)
+ if not ok then
+ ngx.say(err)
+ return
+ end
+ ngx.say("done")
+ }
+ }
+--- response_body
+done
+
+
+
+=== TEST 17: metadata schema rejects invalid ttl
+--- config
+ location /t {
+ content_by_lua_block {
+ local plugin = require("apisix.plugins.oas-validator")
+ local core = require("apisix.core")
+ local ok, err = plugin.check_schema({spec_url_ttl = 0},
core.schema.TYPE_METADATA)
+ if not ok then
+ ngx.say("rejected")
+ return
+ end
+ ngx.say("ok")
+ }
+ }
+--- response_body
+rejected
+
+
+
+=== TEST 18: set plugin metadata with custom TTL
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local code, body =
t.test('/apisix/admin/plugin_metadata/oas-validator',
+ ngx.HTTP_PUT,
+ [[{
+ "spec_url_ttl": 2
+ }]]
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 19: create route with spec_url for TTL test
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local code, body = t.test('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "uri": "/*",
+ "plugins": {
+ "oas-validator": {
+ "spec_url": "http://127.0.0.1:1979/spec.json"
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1970": 1
+ }
+ }
+ }]]
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 20: first request fetches and caches spec
+--- request
+POST /api/v3/pet
+{"id": 10, "name": "doggie", "category": {"id": 1, "name": "Dogs"},
"photoUrls": ["string"], "tags": [{"id": 1, "name": "tag1"}], "status":
"available"}
+--- more_headers
+Content-Type: application/json
+--- no_error_log
+[error]
+
+
+
+=== TEST 21: second request uses cached spec (no refetch)
+--- request
+POST /api/v3/pet
+{"id": 10, "name": "doggie", "category": {"id": 1, "name": "Dogs"},
"photoUrls": ["string"], "tags": [{"id": 1, "name": "tag1"}], "status":
"available"}
+--- more_headers
+Content-Type: application/json
+--- no_error_log
+[error]
+
+
+
+=== TEST 22: after TTL expiry, stale validator still works (async refresh)
+--- config
+ location /t {
+ content_by_lua_block {
+ ngx.sleep(3)
+ local http = require("resty.http")
+ local httpc = http.new()
+ local res, err = httpc:request_uri("http://127.0.0.1:" ..
ngx.var.server_port .. "/api/v3/pet", {
+ method = "POST",
+ body = '{"id": 10, "name": "doggie", "category": {"id": 1,
"name": "Dogs"}, "photoUrls": ["string"], "tags": [{"id": 1, "name": "tag1"}],
"status": "available"}',
+ headers = {
+ ["Content-Type"] = "application/json",
+ }
+ })
+ if not res then
+ ngx.say("request failed: " .. err)
+ return
+ end
+ ngx.say("status: " .. res.status)
+ }
+ }
+--- response_body
+status: 200
+--- no_error_log
+[error]
+
+
+
+=== TEST 23: clean up metadata
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local code, body =
t.test('/apisix/admin/plugin_metadata/oas-validator',
+ ngx.HTTP_DELETE
+ )
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
diff --git a/t/spec/spec.json b/t/spec/spec.json
new file mode 100644
index 000000000..c993fbfec
--- /dev/null
+++ b/t/spec/spec.json
@@ -0,0 +1,834 @@
+{
+ "openapi": "3.0.2",
+ "info": {
+ "title": "Swagger Petstore - OpenAPI 3.0",
+ "description": "This is a sample Pet Store Server based on the OpenAPI 3.0
specification. You can find out more about\nSwagger at
[http://swagger.io](http://swagger.io). In the third iteration of the pet
store, we've switched to the design first approach!\nYou can now help us
improve the API whether it's by making changes to the definition itself or to
the code.\nThat way, with time, we can improve the API in general, and expose
some of the new features in OAS3.\n\nSome useful links [...]
+ "termsOfService": "http://swagger.io/terms/",
+ "contact": { "email": "[email protected]" },
+ "license": {
+ "name": "Apache 2.0",
+ "url": "http://www.apache.org/licenses/LICENSE-2.0.html"
+ },
+ "version": "1.0.17"
+ },
+ "externalDocs": {
+ "description": "Find out more about Swagger",
+ "url": "http://swagger.io"
+ },
+ "servers": [{ "url": "/api/v3" }],
+ "tags": [
+ {
+ "name": "pet",
+ "description": "Everything about your Pets",
+ "externalDocs": {
+ "description": "Find out more",
+ "url": "http://swagger.io"
+ }
+ },
+ {
+ "name": "store",
+ "description": "Access to Petstore orders",
+ "externalDocs": {
+ "description": "Find out more about our store",
+ "url": "http://swagger.io"
+ }
+ },
+ { "name": "user", "description": "Operations about user" }
+ ],
+ "paths": {
+ "/multipleoftest": {
+ "post": {
+ "tags": ["pet"],
+ "summary": "Test multipleOf validation",
+ "operationId": "testMultipleOf",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MultipleOfTest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "successful operation",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "message": { "type": "string" }
+ }
+ }
+ }
+ }
+ },
+ "400": { "description": "Invalid input" }
+ }
+ }
+ },
+ "/pet": {
+ "put": {
+ "tags": ["pet"],
+ "summary": "Update an existing pet",
+ "description": "Update an existing pet by Id",
+ "operationId": "updatePet",
+ "requestBody": {
+ "description": "Update an existent pet in the store",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/Pet" }
+ },
+ "application/xml": {
+ "schema": { "$ref": "#/components/schemas/Pet" }
+ },
+ "application/x-www-form-urlencoded": {
+ "schema": { "$ref": "#/components/schemas/Pet" }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful operation",
+ "content": {
+ "application/xml": {
+ "schema": { "$ref": "#/components/schemas/Pet" }
+ },
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/Pet" }
+ }
+ }
+ },
+ "400": { "description": "Invalid ID supplied" },
+ "404": { "description": "Pet not found" },
+ "405": { "description": "Validation exception" }
+ },
+ "security": [{ "petstore_auth": ["write:pets", "read:pets"] }]
+ },
+ "post": {
+ "tags": ["pet"],
+ "summary": "Add a new pet to the store",
+ "description": "Add a new pet to the store",
+ "operationId": "addPet",
+ "requestBody": {
+ "description": "Create a new pet in the store",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/Pet" }
+ },
+ "application/xml": {
+ "schema": { "$ref": "#/components/schemas/Pet" }
+ },
+ "application/x-www-form-urlencoded": {
+ "schema": { "$ref": "#/components/schemas/Pet" }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful operation",
+ "content": {
+ "application/xml": {
+ "schema": { "$ref": "#/components/schemas/Pet" }
+ },
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/Pet" }
+ }
+ }
+ },
+ "405": { "description": "Invalid input" }
+ },
+ "security": [{ "petstore_auth": ["write:pets", "read:pets"] }]
+ }
+ },
+ "/pet/findByStatus": {
+ "get": {
+ "tags": ["pet"],
+ "summary": "Finds Pets by status",
+ "description": "Multiple status values can be provided with comma
separated strings",
+ "operationId": "findPetsByStatus",
+ "parameters": [
+ {
+ "name": "status",
+ "in": "query",
+ "description": "Status values that need to be considered for
filter",
+ "required": false,
+ "explode": true,
+ "schema": {
+ "type": "string",
+ "default": "available",
+ "enum": ["available", "pending", "sold"]
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "successful operation",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "type": "array",
+ "items": { "$ref": "#/components/schemas/Pet" }
+ }
+ },
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": { "$ref": "#/components/schemas/Pet" }
+ }
+ }
+ }
+ },
+ "400": { "description": "Invalid status value" }
+ },
+ "security": [{ "petstore_auth": ["write:pets", "read:pets"] }]
+ }
+ },
+ "/pet/findByTags": {
+ "get": {
+ "tags": ["pet"],
+ "summary": "Finds Pets by tags",
+ "description": "Multiple tags can be provided with comma separated
strings. Use tag1, tag2, tag3 for testing.",
+ "operationId": "findPetsByTags",
+ "parameters": [
+ {
+ "name": "tags",
+ "in": "query",
+ "description": "Tags to filter by",
+ "required": false,
+ "explode": true,
+ "schema": { "type": "array", "items": { "type": "string" } }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "successful operation",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "type": "array",
+ "items": { "$ref": "#/components/schemas/Pet" }
+ }
+ },
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": { "$ref": "#/components/schemas/Pet" }
+ }
+ }
+ }
+ },
+ "400": { "description": "Invalid tag value" }
+ },
+ "security": [{ "petstore_auth": ["write:pets", "read:pets"] }]
+ }
+ },
+ "/pet/{petId}": {
+ "get": {
+ "tags": ["pet"],
+ "summary": "Find pet by ID",
+ "description": "Returns a single pet",
+ "operationId": "getPetById",
+ "parameters": [
+ {
+ "name": "petId",
+ "in": "path",
+ "description": "ID of pet to return",
+ "required": true,
+ "schema": { "type": "integer", "format": "int64" }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "successful operation",
+ "content": {
+ "application/xml": {
+ "schema": { "$ref": "#/components/schemas/Pet" }
+ },
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/Pet" }
+ }
+ }
+ },
+ "400": { "description": "Invalid ID supplied" },
+ "404": { "description": "Pet not found" }
+ },
+ "security": [
+ { "api_key": [] },
+ { "petstore_auth": ["write:pets", "read:pets"] }
+ ]
+ },
+ "post": {
+ "tags": ["pet"],
+ "summary": "Updates a pet in the store with form data",
+ "description": "",
+ "operationId": "updatePetWithForm",
+ "parameters": [
+ {
+ "name": "petId",
+ "in": "path",
+ "description": "ID of pet that needs to be updated",
+ "required": true,
+ "schema": { "type": "integer", "format": "int64" }
+ },
+ {
+ "name": "name",
+ "in": "query",
+ "description": "Name of pet that needs to be updated",
+ "schema": { "type": "string" }
+ },
+ {
+ "name": "status",
+ "in": "query",
+ "description": "Status of pet that needs to be updated",
+ "schema": { "type": "string" }
+ }
+ ],
+ "responses": { "405": { "description": "Invalid input" } },
+ "security": [{ "petstore_auth": ["write:pets", "read:pets"] }]
+ },
+ "delete": {
+ "tags": ["pet"],
+ "summary": "Deletes a pet",
+ "description": "",
+ "operationId": "deletePet",
+ "parameters": [
+ {
+ "name": "api_key",
+ "in": "header",
+ "description": "",
+ "required": false,
+ "schema": { "type": "string" }
+ },
+ {
+ "name": "petId",
+ "in": "path",
+ "description": "Pet id to delete",
+ "required": true,
+ "schema": { "type": "integer", "format": "int64" }
+ }
+ ],
+ "responses": { "400": { "description": "Invalid pet value" } },
+ "security": [{ "petstore_auth": ["write:pets", "read:pets"] }]
+ }
+ },
+ "/pet/{petId}/uploadImage": {
+ "post": {
+ "tags": ["pet"],
+ "summary": "uploads an image",
+ "description": "",
+ "operationId": "uploadFile",
+ "parameters": [
+ {
+ "name": "petId",
+ "in": "path",
+ "description": "ID of pet to update",
+ "required": true,
+ "schema": { "type": "integer", "format": "int64" }
+ },
+ {
+ "name": "additionalMetadata",
+ "in": "query",
+ "description": "Additional Metadata",
+ "required": false,
+ "schema": { "type": "string" }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/octet-stream": {
+ "schema": { "type": "string", "format": "binary" }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "successful operation",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/ApiResponse" }
+ }
+ }
+ }
+ },
+ "security": [{ "petstore_auth": ["write:pets", "read:pets"] }]
+ }
+ },
+ "/store/inventory": {
+ "get": {
+ "tags": ["store"],
+ "summary": "Returns pet inventories by status",
+ "description": "Returns a map of status codes to quantities",
+ "operationId": "getInventory",
+ "responses": {
+ "200": {
+ "description": "successful operation",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "integer",
+ "format": "int32"
+ }
+ }
+ }
+ }
+ }
+ },
+ "security": [{ "api_key": [] }]
+ }
+ },
+ "/store/order": {
+ "post": {
+ "tags": ["store"],
+ "summary": "Place an order for a pet",
+ "description": "Place a new order in the store",
+ "operationId": "placeOrder",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/Order" }
+ },
+ "application/xml": {
+ "schema": { "$ref": "#/components/schemas/Order" }
+ },
+ "application/x-www-form-urlencoded": {
+ "schema": { "$ref": "#/components/schemas/Order" }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "successful operation",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/Order" }
+ }
+ }
+ },
+ "405": { "description": "Invalid input" }
+ }
+ }
+ },
+ "/store/order/{orderId}": {
+ "get": {
+ "tags": ["store"],
+ "summary": "Find purchase order by ID",
+ "description": "For valid response try integer IDs with value <= 5 or
> 10. Other values will generate exceptions.",
+ "operationId": "getOrderById",
+ "parameters": [
+ {
+ "name": "orderId",
+ "in": "path",
+ "description": "ID of order that needs to be fetched",
+ "required": true,
+ "schema": { "type": "integer", "format": "int64" }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "successful operation",
+ "content": {
+ "application/xml": {
+ "schema": { "$ref": "#/components/schemas/Order" }
+ },
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/Order" }
+ }
+ }
+ },
+ "400": { "description": "Invalid ID supplied" },
+ "404": { "description": "Order not found" }
+ }
+ },
+ "delete": {
+ "tags": ["store"],
+ "summary": "Delete purchase order by ID",
+ "description": "For valid response try integer IDs with value < 1000.
Anything above 1000 or nonintegers will generate API errors",
+ "operationId": "deleteOrder",
+ "parameters": [
+ {
+ "name": "orderId",
+ "in": "path",
+ "description": "ID of the order that needs to be deleted",
+ "required": true,
+ "schema": { "type": "integer", "format": "int64" }
+ }
+ ],
+ "responses": {
+ "400": { "description": "Invalid ID supplied" },
+ "404": { "description": "Order not found" }
+ }
+ }
+ },
+ "/user": {
+ "post": {
+ "tags": ["user"],
+ "summary": "Create user",
+ "description": "This can only be done by the logged in user.",
+ "operationId": "createUser",
+ "requestBody": {
+ "description": "Created user object",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/User" }
+ },
+ "application/xml": {
+ "schema": { "$ref": "#/components/schemas/User" }
+ },
+ "application/x-www-form-urlencoded": {
+ "schema": { "$ref": "#/components/schemas/User" }
+ }
+ }
+ },
+ "responses": {
+ "default": {
+ "description": "successful operation",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/User" }
+ },
+ "application/xml": {
+ "schema": { "$ref": "#/components/schemas/User" }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/user/createWithList": {
+ "post": {
+ "tags": ["user"],
+ "summary": "Creates list of users with given input array",
+ "description": "Creates list of users with given input array",
+ "operationId": "createUsersWithListInput",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": { "$ref": "#/components/schemas/User" }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful operation",
+ "content": {
+ "application/xml": {
+ "schema": { "$ref": "#/components/schemas/User" }
+ },
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/User" }
+ }
+ }
+ },
+ "default": { "description": "successful operation" }
+ }
+ }
+ },
+ "/user/login": {
+ "get": {
+ "tags": ["user"],
+ "summary": "Logs user into the system",
+ "description": "",
+ "operationId": "loginUser",
+ "parameters": [
+ {
+ "name": "username",
+ "in": "query",
+ "description": "The user name for login",
+ "required": false,
+ "schema": { "type": "string" }
+ },
+ {
+ "name": "password",
+ "in": "query",
+ "description": "The password for login in clear text",
+ "required": false,
+ "schema": { "type": "string" }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "successful operation",
+ "headers": {
+ "X-Rate-Limit": {
+ "description": "calls per hour allowed by the user",
+ "schema": { "type": "integer", "format": "int32" }
+ },
+ "X-Expires-After": {
+ "description": "date in UTC when token expires",
+ "schema": { "type": "string", "format": "date-time" }
+ }
+ },
+ "content": {
+ "application/xml": { "schema": { "type": "string" } },
+ "application/json": { "schema": { "type": "string" } }
+ }
+ },
+ "400": { "description": "Invalid username/password supplied" }
+ }
+ }
+ },
+ "/user/logout": {
+ "get": {
+ "tags": ["user"],
+ "summary": "Logs out current logged in user session",
+ "description": "",
+ "operationId": "logoutUser",
+ "parameters": [],
+ "responses": { "default": { "description": "successful operation" } }
+ }
+ },
+ "/user/{username}": {
+ "get": {
+ "tags": ["user"],
+ "summary": "Get user by user name",
+ "description": "",
+ "operationId": "getUserByName",
+ "parameters": [
+ {
+ "name": "username",
+ "in": "path",
+ "description": "The name that needs to be fetched. Use user1 for
testing. ",
+ "required": true,
+ "schema": { "type": "string" }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "successful operation",
+ "content": {
+ "application/xml": {
+ "schema": { "$ref": "#/components/schemas/User" }
+ },
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/User" }
+ }
+ }
+ },
+ "400": { "description": "Invalid username supplied" },
+ "404": { "description": "User not found" }
+ }
+ },
+ "put": {
+ "tags": ["user"],
+ "summary": "Update user",
+ "description": "This can only be done by the logged in user.",
+ "operationId": "updateUser",
+ "parameters": [
+ {
+ "name": "username",
+ "in": "path",
+ "description": "name that need to be deleted",
+ "required": true,
+ "schema": { "type": "string" }
+ }
+ ],
+ "requestBody": {
+ "description": "Update an existent user in the store",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/User" }
+ },
+ "application/xml": {
+ "schema": { "$ref": "#/components/schemas/User" }
+ },
+ "application/x-www-form-urlencoded": {
+ "schema": { "$ref": "#/components/schemas/User" }
+ }
+ }
+ },
+ "responses": { "default": { "description": "successful operation" } }
+ },
+ "delete": {
+ "tags": ["user"],
+ "summary": "Delete user",
+ "description": "This can only be done by the logged in user.",
+ "operationId": "deleteUser",
+ "parameters": [
+ {
+ "name": "username",
+ "in": "path",
+ "description": "The name that needs to be deleted",
+ "required": true,
+ "schema": { "type": "string" }
+ }
+ ],
+ "responses": {
+ "400": { "description": "Invalid username supplied" },
+ "404": { "description": "User not found" }
+ }
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "MultipleOfTest": {
+ "type": "object",
+ "required": ["testnumber"],
+ "properties": {
+ "testnumber": {
+ "type": "number",
+ "multipleOf": 0.01
+ }
+ }
+ },
+ "Order": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "integer", "format": "int64", "example": 10 },
+ "petId": { "type": "integer", "format": "int64", "example": 198772 },
+ "quantity": { "type": "integer", "format": "int32", "example": 7 },
+ "shipDate": { "type": "string", "format": "date-time" },
+ "status": {
+ "type": "string",
+ "description": "Order Status",
+ "example": "approved",
+ "enum": ["placed", "approved", "delivered"]
+ },
+ "complete": { "type": "boolean" }
+ },
+ "xml": { "name": "order" }
+ },
+ "Customer": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "integer", "format": "int64", "example": 100000 },
+ "username": { "type": "string", "example": "fehguy" },
+ "address": {
+ "type": "array",
+ "xml": { "name": "addresses", "wrapped": true },
+ "items": { "$ref": "#/components/schemas/Address" }
+ }
+ },
+ "xml": { "name": "customer" }
+ },
+ "Address": {
+ "type": "object",
+ "properties": {
+ "street": { "type": "string", "example": "437 Lytton" },
+ "city": { "type": "string", "example": "Palo Alto" },
+ "state": { "type": "string", "example": "CA" },
+ "zip": { "type": "string", "example": "94301" }
+ },
+ "xml": { "name": "address" }
+ },
+ "Category": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "integer", "format": "int64", "example": 1 },
+ "name": { "type": "string", "example": "Dogs" }
+ },
+ "xml": { "name": "category" }
+ },
+ "User": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "integer", "format": "int64", "example": 10 },
+ "username": { "type": "string", "example": "theUser" },
+ "firstName": { "type": "string", "example": "John" },
+ "lastName": { "type": "string", "example": "James" },
+ "email": { "type": "string", "example": "[email protected]" },
+ "password": { "type": "string", "example": "12345" },
+ "phone": { "type": "string", "example": "12345" },
+ "userStatus": {
+ "type": "integer",
+ "description": "User Status",
+ "format": "int32",
+ "example": 1
+ }
+ },
+ "xml": { "name": "user" }
+ },
+ "Tag": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "integer", "format": "int64" },
+ "name": { "type": "string" }
+ },
+ "xml": { "name": "tag" }
+ },
+ "Pet": {
+ "required": ["name", "photoUrls"],
+ "type": "object",
+ "properties": {
+ "id": { "type": "integer", "format": "int64", "example": 10 },
+ "name": { "type": "string", "example": "doggie" },
+ "category": { "$ref": "#/components/schemas/Category" },
+ "photoUrls": {
+ "type": "array",
+ "xml": { "wrapped": true },
+ "items": { "type": "string", "xml": { "name": "photoUrl" } }
+ },
+ "tags": {
+ "type": "array",
+ "xml": { "wrapped": true },
+ "items": { "$ref": "#/components/schemas/Tag" }
+ },
+ "status": {
+ "type": "string",
+ "description": "pet status in the store",
+ "enum": ["available", "pending", "sold"]
+ }
+ },
+ "xml": { "name": "pet" }
+ },
+ "ApiResponse": {
+ "type": "object",
+ "properties": {
+ "code": { "type": "integer", "format": "int32" },
+ "type": { "type": "string" },
+ "message": { "type": "string" }
+ },
+ "xml": { "name": "##default" }
+ }
+ },
+ "requestBodies": {
+ "Pet": {
+ "description": "Pet object that needs to be added to the store",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/Pet" }
+ },
+ "application/xml": {
+ "schema": { "$ref": "#/components/schemas/Pet" }
+ }
+ }
+ },
+ "UserArray": {
+ "description": "List of user object",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": { "$ref": "#/components/schemas/User" }
+ }
+ }
+ }
+ }
+ },
+ "securitySchemes": {
+ "petstore_auth": {
+ "type": "oauth2",
+ "flows": {
+ "implicit": {
+ "authorizationUrl": "https://petstore3.swagger.io/oauth/authorize",
+ "scopes": {
+ "write:pets": "modify pets in your account",
+ "read:pets": "read your pets"
+ }
+ }
+ }
+ },
+ "api_key": { "type": "apiKey", "name": "api_key", "in": "header" }
+ }
+ }
+}
diff --git a/t/spec/spec31-gaps.json b/t/spec/spec31-gaps.json
new file mode 100644
index 000000000..39364eb19
--- /dev/null
+++ b/t/spec/spec31-gaps.json
@@ -0,0 +1,193 @@
+{
+ "openapi": "3.1.0",
+ "info": {
+ "title": "OAS 3.1 Gap Tests",
+ "version": "1.0.0"
+ },
+ "servers": [{ "url": "/api/v31gap" }],
+ "paths": {
+ "/widget": {
+ "$ref": "#/components/pathItems/WidgetPath"
+ },
+ "/widget-inline": {
+ "post": {
+ "operationId": "createWidgetInline",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/Widget" }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "ok" }
+ }
+ }
+ },
+ "/item": {
+ "post": {
+ "operationId": "createItem",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/ItemNotTest" }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "ok" }
+ }
+ }
+ },
+ "/pattern": {
+ "post": {
+ "operationId": "createPattern",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/PatternTest" }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "ok" }
+ }
+ }
+ },
+ "/dynref": {
+ "post": {
+ "operationId": "createDynref",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/DynRefTest" }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "ok" }
+ }
+ }
+ },
+ "/content-annotation": {
+ "post": {
+ "operationId": "createContentAnnotation",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/ContentAnnotationTest"
}
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "ok" }
+ }
+ }
+ },
+ "/prefixitems": {
+ "post": {
+ "operationId": "createPrefixItems",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/PrefixItemsTest" }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "ok" }
+ }
+ }
+ }
+ },
+ "components": {
+ "pathItems": {
+ "WidgetPath": {
+ "post": {
+ "operationId": "createWidget",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/Widget" }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "ok" }
+ }
+ }
+ }
+ },
+ "schemas": {
+ "Widget": {
+ "type": "object",
+ "required": ["name"],
+ "properties": {
+ "name": { "type": "string" }
+ }
+ },
+ "ItemNotTest": {
+ "type": "object",
+ "required": ["value"],
+ "properties": {
+ "value": {
+ "not": { "type": "integer" },
+ "description": "must not be an integer"
+ }
+ }
+ },
+ "PatternTest": {
+ "type": "object",
+ "patternProperties": {
+ "^S_": { "type": "string" },
+ "^I_": { "type": "integer" }
+ },
+ "additionalProperties": false
+ },
+ "DynRefTest": {
+ "type": "object",
+ "required": ["items"],
+ "properties": {
+ "items": {
+ "type": "array",
+ "items": { "$dynamicRef": "#items" }
+ }
+ },
+ "$defs": {
+ "defaultItem": {
+ "$dynamicAnchor": "items",
+ "type": "string"
+ }
+ }
+ },
+ "ContentAnnotationTest": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "type": "string",
+ "contentMediaType": "application/json",
+ "contentEncoding": "base64",
+ "description": "content* keywords are annotations only; any string
passes"
+ }
+ }
+ },
+ "PrefixItemsTest": {
+ "type": "array",
+ "prefixItems": [
+ { "type": "string" },
+ { "type": "integer" }
+ ],
+ "items": { "type": "boolean" },
+ "description": "first two items: string then integer; additional items
must be boolean"
+ }
+ }
+ }
+}
diff --git a/t/spec/spec31.json b/t/spec/spec31.json
new file mode 100644
index 000000000..c46b78a00
--- /dev/null
+++ b/t/spec/spec31.json
@@ -0,0 +1,300 @@
+{
+ "openapi": "3.1.0",
+ "info": {
+ "title": "Pet Store - OpenAPI 3.1",
+ "version": "1.0.0"
+ },
+ "servers": [{ "url": "/api/v31" }],
+ "paths": {
+ "/pet": {
+ "post": {
+ "operationId": "addPet31",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/Pet" }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "successful operation" },
+ "400": { "description": "Invalid input" }
+ }
+ }
+ },
+ "/pet/{petId}": {
+ "get": {
+ "operationId": "getPet31",
+ "parameters": [
+ {
+ "name": "petId",
+ "in": "path",
+ "required": true,
+ "schema": { "type": "integer" }
+ }
+ ],
+ "responses": {
+ "200": { "description": "successful operation" },
+ "400": { "description": "Invalid ID" }
+ }
+ }
+ },
+ "/pet/findByStatus": {
+ "get": {
+ "operationId": "findPetsByStatus31",
+ "parameters": [
+ {
+ "name": "status",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "enum": ["available", "pending", "sold"]
+ }
+ }
+ ],
+ "responses": {
+ "200": { "description": "successful operation" },
+ "400": { "description": "Invalid status" }
+ }
+ }
+ },
+ "/nullable": {
+ "post": {
+ "operationId": "nullableTest",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/NullableTest" }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "ok" }
+ }
+ }
+ },
+ "/const": {
+ "post": {
+ "operationId": "constTest",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/ConstTest" }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "ok" }
+ }
+ }
+ },
+ "/multipleoftest": {
+ "post": {
+ "operationId": "multipleOfTest31",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/MultipleOfTest" }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "ok" }
+ }
+ }
+ },
+ "/exclusive": {
+ "post": {
+ "operationId": "exclusiveTest31",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/ExclusiveTest" }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "ok" }
+ }
+ }
+ },
+ "/shape": {
+ "post": {
+ "operationId": "shapeTest31",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/Shape" }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "ok" }
+ }
+ }
+ },
+ "/anyof": {
+ "post": {
+ "operationId": "anyOfTest31",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/AnyOfTest" }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "ok" }
+ }
+ }
+ },
+ "/oneof": {
+ "post": {
+ "operationId": "oneOfTest31",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/OneOfTest" }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "ok" }
+ }
+ }
+ },
+ "/allof": {
+ "post": {
+ "operationId": "allOfTest31",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/AllOfTest" }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "ok" }
+ }
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "Pet": {
+ "type": "object",
+ "required": ["name", "photoUrls"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "photoUrls": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "status": {
+ "type": "string",
+ "enum": ["available", "pending", "sold"]
+ },
+ "tag": { "$ref": "#/components/schemas/Tag" }
+ }
+ },
+ "Tag": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" }
+ }
+ },
+ "NullableTest": {
+ "type": "object",
+ "required": ["value"],
+ "properties": {
+ "value": {
+ "type": ["string", "null"],
+ "description": "OAS 3.1 nullable via type array"
+ }
+ }
+ },
+ "ConstTest": {
+ "type": "object",
+ "required": ["version"],
+ "properties": {
+ "version": {
+ "const": "v1",
+ "description": "OAS 3.1 const keyword"
+ }
+ }
+ },
+ "MultipleOfTest": {
+ "type": "object",
+ "required": ["testnumber"],
+ "properties": {
+ "testnumber": {
+ "type": "number",
+ "multipleOf": 0.01
+ }
+ }
+ },
+ "ExclusiveTest": {
+ "type": "object",
+ "required": ["score"],
+ "properties": {
+ "score": {
+ "type": "number",
+ "exclusiveMinimum": 0,
+ "exclusiveMaximum": 100
+ }
+ }
+ },
+ "Shape": {
+ "type": "object",
+ "required": ["type"],
+ "properties": {
+ "type": { "type": "string" }
+ },
+ "if": {
+ "properties": { "type": { "const": "circle" } }
+ },
+ "then": {
+ "required": ["radius"],
+ "properties": { "radius": { "type": "number" } }
+ },
+ "else": {
+ "required": ["width", "height"],
+ "properties": {
+ "width": { "type": "number" },
+ "height": { "type": "number" }
+ }
+ }
+ },
+ "AnyOfTest": {
+ "anyOf": [
+ { "type": "object", "required": ["name"], "properties": { "name": {
"type": "string" } } },
+ { "type": "object", "required": ["id"], "properties": { "id": {
"type": "integer" } } }
+ ]
+ },
+ "OneOfTest": {
+ "oneOf": [
+ { "type": "object", "required": ["cat"], "properties": { "cat": {
"type": "string" } } },
+ { "type": "object", "required": ["dog"], "properties": { "dog": {
"type": "string" } } }
+ ]
+ },
+ "AllOfTest": {
+ "allOf": [
+ { "type": "object", "required": ["a"], "properties": { "a": {
"type": "string" } } },
+ { "type": "object", "required": ["b"], "properties": { "b": {
"type": "integer" } } }
+ ]
+ }
+ }
+ }
+}