This is an automated email from the ASF dual-hosted git repository.
AlexStocks pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/dubbo-go-pixiu.git
The following commit(s) were added to refs/heads/develop by this push:
new daaeb6e1f Feature/openapi sdk validator (#948)
daaeb6e1f is described below
commit daaeb6e1f1953403e737155807e50a558034369c
Author: Skylm808 <[email protected]>
AuthorDate: Mon Jun 29 09:37:24 2026 +0800
Feature/openapi sdk validator (#948)
* Add OpenAPI request validation filter
---------
Co-authored-by: Tianlm <[email protected]>
---
docs/user/filter/openapi.md | 94 ++-
docs/user/filter/openapi_CN.md | 88 ++-
go.mod | 23 +-
go.sum | 43 +-
pkg/common/constant/key.go | 1 +
pkg/context/http/erroresponse.go | 3 +
pkg/filter/http/apiconfig/api_config.go | 57 +-
pkg/filter/http/apiconfig/api_config_test.go | 249 +++---
pkg/filter/http/apiconfig/config.go | 37 +
pkg/filter/http/apiconfig/openapi/compiler.go | 284 -------
pkg/filter/http/apiconfig/openapi/compiler_test.go | 203 -----
pkg/filter/http/apiconfig/openapi/doc.go | 20 -
pkg/filter/http/apiconfig/openapi/metadata.go | 37 -
pkg/filter/http/apiconfig/openapi/model.go | 55 --
pkg/filter/http/apiconfig/openapi/validator.go | 292 --------
.../http/apiconfig/openapi/validator_test.go | 201 -----
pkg/filter/http/openapi/openapi.go | 567 ++++++++++++++
pkg/filter/http/openapi/openapi_test.go | 834 +++++++++++++++++++++
pkg/pluginregistry/registry.go | 1 +
19 files changed, 1711 insertions(+), 1378 deletions(-)
diff --git a/docs/user/filter/openapi.md b/docs/user/filter/openapi.md
index 10c80a343..593050540 100644
--- a/docs/user/filter/openapi.md
+++ b/docs/user/filter/openapi.md
@@ -6,35 +6,34 @@ English | [中文](openapi_CN.md)
## Overview
-Pixiu can load an OpenAPI 3.x file during `dgp.filter.http.apiconfig`
initialization and merge each operation's
-`ValidationPlan` into the matching route that is already defined by
`api_config`.
+Pixiu can load a local OpenAPI 3.0/3.1 file in `dgp.filter.http.openapi` and
validate matching requests before they
+are forwarded upstream. OpenAPI 3.2 documents are supported for the standard
HTTP operations wired by this filter.
-When a request matches an API, Pixiu validates the request before it is
forwarded upstream.
+Official references:
-If validation fails, Pixiu returns a `400 Bad Request` locally and stops the
filter chain.
+- [libopenapi](https://github.com/pb33f/libopenapi)
+- [libopenapi validation](https://pb33f.io/libopenapi/validation/)
+- [libopenapi validator](https://github.com/pb33f/libopenapi-validator)
-## Supported In V1
+If validation fails, Pixiu returns a local `400 Bad Request` and stops the
filter chain.
-- local OpenAPI 3.x file loading
-- path parameter extraction
-- query parameter validation
-- header validation
+## Wired In This Filter
+
+- local OpenAPI 3.0/3.1 file loading, plus OpenAPI 3.2 documents that use
standard HTTP operations
+- request matching by OpenAPI path and method, including templated paths such
as `/users/{id}`
+- path, query, and header parameter validation
- JSON request body validation
-- `required`
-- `type`
-- `enum`
-- `minimum`
-- `maximum`
-- `minLength`
-- `maxLength`
+- OpenAPI schema constraints enforced by the SDK, including `required`,
`type`, `enum`, `minimum`, `maximum`, `minLength`, and `maxLength`
+
+This filter uses `libopenapi` to parse the spec and `libopenapi-validator` to
validate incoming requests.
-## Not Included In V1
+## Not Wired In This Filter
- response validation
-- remote `$ref`
-- `oneOf` / `allOf` / `anyOf`
+- route creation or `api_config` route matching
+- OpenAPI `security` validation; use dedicated authentication or authorization
filters for auth checks
+- OpenAPI 3.2 operations outside the standard HTTP request methods wired by
this filter
- admin or config-center distribution of OpenAPI files
-- `dynamic + openapi_path` combined configuration
## Example Filter Config
@@ -42,28 +41,57 @@ If validation fails, Pixiu returns a `400 Bad Request`
locally and stops the fil
- name: dgp.filter.http.apiconfig
config:
path: configs/api_config.yaml
- openapi_path: configs/openapi_users.yaml
- enable_openapi_validation: true
+
+- name: dgp.filter.http.openapi
+ config:
+ path: configs/openapi_users.yaml
+ # Optional. Defaults to 1048576 bytes.
+ max_request_body_bytes: 1048576
```
-OpenAPI validation does not create standalone routes. The route must already
exist in `api_config`.
+`dgp.filter.http.apiconfig` and `dgp.filter.http.openapi` are independent
filters. `apiconfig` matches Pixiu API routes
+and writes API metadata into the request context. `openapi` validates only the
operations declared in the OpenAPI file.
+Deprecated OpenAPI validation keys in `apiconfig`, including `openapi_path`
and `enable_openapi_validation`, are rejected
+when present, even when set to `""` or `false`. Configure
`dgp.filter.http.openapi` instead.
+
+If a request path and method are not declared in the OpenAPI file, this filter
skips validation and lets the request
+continue. If the operation is declared but the request violates parameters or
body schema, Pixiu returns
+`400 Bad Request`.
## Notes
+- `libopenapi-validator` is an opt-in companion module; `libopenapi` handles
parsing and model building.
- Parameter-level validation covers common scalar constraints for `path`,
`query`, and `header` parameters.
-- V1 does not support using `dynamic` together with `openapi_path` in the same
filter config.
+- The keywords listed above come from the OpenAPI schema and are enforced by
the SDK path, not by custom in-repo validators.
+- OpenAPI `security` validation is disabled for this filter, so auth remains
the responsibility of filters such as JWT,
+ OPA, SAML, or other dedicated authentication and authorization filters.
+- The configured OpenAPI `path` must be relative. Absolute paths,
parent-directory segments such as `..`, and sensitive
+ base directories are rejected during filter startup. Symlinks are resolved
before the check so that a relative path
+ cannot bypass the sensitive-directory guard via a symbolic link.
+- Request bodies are capped by `max_request_body_bytes` before schema
validation. This prevents the validator from
+ reading unbounded JSON or chunked bodies in the gateway hot path.
+- Omit `max_request_body_bytes` or set it to `0` to use the default `1048576`
byte limit.
+- Relative file references are resolved from the OpenAPI file location, so
file-based loading keeps local `$ref` paths intact.
+- Invalid OpenAPI documents, including unresolved `$ref` targets, fail during
filter startup instead of running with a
+ partial validator model.
+- The validator package also exposes response and document validation APIs,
but this filter only calls the request-validation path.
## Runtime Flow
-1. Pixiu loads `api_config` during `apiconfig.Apply()`.
-2. Pixiu loads the OpenAPI file and compiles validation plans.
-3. Each compiled `ValidationPlan` is merged into the matching
`router.API.Metadata`.
-4. A request enters `apiconfig.Decode()`.
-5. Pixiu matches the request path and method.
-6. Pixiu extracts the `ValidationPlan` from the matched API metadata.
-7. Pixiu validates the request.
-8. If validation succeeds, the request continues to later proxy filters.
-9. If validation fails, Pixiu responds with `400 Bad Request`.
+1. Pixiu loads the OpenAPI file during `openapi.Apply()` and builds an SDK
validator.
+2. A request enters `openapi.Decode()`.
+3. Pixiu checks whether the OpenAPI document declares the request path and
method.
+4. If the operation is not declared, validation is skipped and the request
continues.
+5. If the operation is declared, Pixiu validates the request through
`libopenapi-validator`.
+6. If validation succeeds, the request continues to later filters.
+7. If validation fails, Pixiu responds with `400 Bad Request`.
+
+### HEAD requests
+
+The OpenAPI spec does not treat HEAD as a standard HTTP method. When only
`GET` is declared for a path
+(without an explicit `head` operation), the SDK's `FindPath` treats HEAD as an
undeclared method.
+The filter therefore skips validation and lets the HEAD request continue to
downstream handlers.
+If you need HEAD requests to be validated, declare an explicit `head`
operation in the OpenAPI spec.
## Example Requests
diff --git a/docs/user/filter/openapi_CN.md b/docs/user/filter/openapi_CN.md
index e8a0b2ad9..f3c07c955 100644
--- a/docs/user/filter/openapi_CN.md
+++ b/docs/user/filter/openapi_CN.md
@@ -6,35 +6,34 @@
## 概述
-Pixiu 可以在 `dgp.filter.http.apiconfig` 初始化阶段加载 OpenAPI 3.x 文件,并把每个 operation 的
-`ValidationPlan` 合并到 `api_config` 里已经存在的同名路由上。
+Pixiu 可以在 `dgp.filter.http.openapi` 中加载本地 OpenAPI 3.0/3.1
文件,并在请求转发到上游之前校验命中的请求。对于
+OpenAPI 3.2 文档,当前仅覆盖这个 filter 已接入的标准 HTTP operation。
-当请求命中 API 后,Pixiu 会在转发到上游之前先执行请求校验。
+官方参考:
-如果校验失败,Pixiu 会直接返回 `400 Bad Request`,并停止后续过滤链。
+- [libopenapi](https://github.com/pb33f/libopenapi)
+- [libopenapi validation](https://pb33f.io/libopenapi/validation/)
+- [libopenapi validator](https://github.com/pb33f/libopenapi-validator)
-## 第一版支持范围
+如果校验失败,Pixiu 会直接返回本地 `400 Bad Request`,并停止后续过滤链。
-- 本地 OpenAPI 3.x 文件加载
-- path 参数提取
-- query 参数校验
-- header 校验
+## 当前 filter 已接入
+
+- 本地 OpenAPI 3.0/3.1 文件加载,以及使用标准 HTTP operation 的 OpenAPI 3.2 文档
+- 按 OpenAPI path 和 method 匹配请求,包括 `/users/{id}` 这类模板路径
+- path、query、header 参数校验
- JSON request body 校验
-- `required`
-- `type`
-- `enum`
-- `minimum`
-- `maximum`
-- `minLength`
-- `maxLength`
+- 由 SDK 执行的 OpenAPI schema 约束,包括
`required`、`type`、`enum`、`minimum`、`maximum`、`minLength`、`maxLength`
+
+当前这个 filter 使用 `libopenapi` 解析 OpenAPI 文档,并使用 `libopenapi-validator`
校验进入过滤器的请求。
-## 第一版暂不支持
+## 当前 filter 未接入
- response validation
-- 远程 `$ref`
-- `oneOf` / `allOf` / `anyOf`
+- 路由创建或 `api_config` 路由匹配
+- OpenAPI `security` 校验;鉴权请使用专门的认证或授权 filter
+- OpenAPI 3.2 中超出当前标准 HTTP request method 覆盖范围的 operation
- admin 或配置中心分发 OpenAPI 文件
-- `dynamic + openapi_path` 组合配置
## 配置示例
@@ -42,28 +41,49 @@ Pixiu 可以在 `dgp.filter.http.apiconfig` 初始化阶段加载 OpenAPI 3.x
- name: dgp.filter.http.apiconfig
config:
path: configs/api_config.yaml
- openapi_path: configs/openapi_users.yaml
- enable_openapi_validation: true
+
+- name: dgp.filter.http.openapi
+ config:
+ path: configs/openapi_users.yaml
+ # 可选,默认 1048576 字节。
+ max_request_body_bytes: 1048576
```
-OpenAPI 校验不会单独创建路由,目标路由必须已经存在于 `api_config` 中。
+`dgp.filter.http.apiconfig` 和 `dgp.filter.http.openapi` 是两个独立
filter。`apiconfig` 负责匹配 Pixiu API 路由,并把
+API 元信息写入请求上下文;`openapi` 只校验 OpenAPI 文件中声明过的 operation。
+`apiconfig` 中已废弃的 OpenAPI 校验配置键,包括 `openapi_path` 和
`enable_openapi_validation`,只要出现就会被拒绝;
+即使配置为 `""` 或 `false` 也一样。请改用独立的 `dgp.filter.http.openapi` filter。
+
+如果请求的 path 和 method 没有在 OpenAPI 文件中声明,这个 filter 会跳过校验并放行请求。如果 operation 已声明但请求
+不满足参数或 body schema,Pixiu 会返回 `400 Bad Request`。
## 说明
-- 参数级校验现在覆盖 `path`、`query`、`header` 上常见的标量类型约束。
-- 第一版不支持在同一个 filter 配置里同时使用 `dynamic` 和 `openapi_path`。
+- `libopenapi-validator` 是独立的可选模块,`libopenapi` 负责解析和建模。
+- 参数级校验覆盖 `path`、`query`、`header` 上常见的标量类型约束。
+- 上面这些关键词来自 OpenAPI schema,是由 SDK 路径执行的,不是仓库里再自定义一套验证器。
+- 当前 filter 关闭了 OpenAPI `security` 校验,鉴权仍由 JWT、OPA、SAML 或其他专门的认证/授权 filter 负责。
+- OpenAPI `path` 配置必须使用相对路径。绝对路径、`..` 父目录跳转和敏感 base 目录会在 filter
启动阶段被拒绝。启动时会解析符号链接(symlink),防止通过相对路径 + 符号链接绕过敏感目录检查。
+- request body 在 schema 校验前会受 `max_request_body_bytes` 限制,避免 validator
在网关热路径上读取无上限 JSON 或 chunked body。
+- 不配置 `max_request_body_bytes` 或配置为 `0` 时,会使用默认的 `1048576` 字节限制。
+- OpenAPI 文件里的相对引用会按文件所在目录解析,所以使用本地文件加载时可以保留本地 `$ref` 路径。
+- 无效 OpenAPI 文档,包括无法解析的 `$ref`,会在 filter 启动阶段失败,不会继续使用部分构建出来的 validator model。
+- `libopenapi-validator` 也提供响应和文档校验 API,但当前这个 filter 只调用了请求校验入口。
## 运行流程
-1. Pixiu 在 `apiconfig.Apply()` 阶段先加载 `api_config`。
-2. Pixiu 再加载 OpenAPI 文件并编译校验计划。
-3. 每个编译结果的 `ValidationPlan` 会合并到匹配的 `router.API.Metadata` 中。
-4. 请求进入 `apiconfig.Decode()`。
-5. Pixiu 先按 path 和 method 匹配 API。
-6. 从命中的 API metadata 中取出 `ValidationPlan`。
-7. Pixiu 执行请求校验。
-8. 校验通过,请求继续流向后续代理过滤器。
-9. 校验失败,Pixiu 直接返回 `400 Bad Request`。
+1. Pixiu 在 `openapi.Apply()` 阶段加载 OpenAPI 文件并构建 SDK 校验器。
+2. 请求进入 `openapi.Decode()`。
+3. Pixiu 检查 OpenAPI 文档是否声明了请求 path 和 method。
+4. 如果 operation 未声明,跳过校验并继续后续过滤链。
+5. 如果 operation 已声明,Pixiu 通过 `libopenapi-validator` 执行请求校验。
+6. 校验通过,请求继续流向后续 filter。
+7. 校验失败,Pixiu 直接返回 `400 Bad Request`。
+
+### HEAD 请求
+
+OpenAPI spec 不把 HEAD 视为标准 HTTP 方法。当路径只声明了 `GET`(没有显式 `head` operation)时,SDK 的
`FindPath` 会把 HEAD 当作未声明方法处理,filter 跳过校验并放行。
+如果需要对 HEAD 请求做校验,可以在 OpenAPI spec 中显式声明 `head` operation。
## 请求示例
diff --git a/go.mod b/go.mod
index 3aff8e2ff..b50f0f277 100644
--- a/go.mod
+++ b/go.mod
@@ -38,7 +38,8 @@ require (
github.com/nacos-group/nacos-sdk-go v1.1.3
github.com/nacos-group/nacos-sdk-go/v2 v2.3.2
github.com/open-policy-agent/opa v0.45.0
- github.com/pb33f/libopenapi v0.28.2
+ github.com/pb33f/libopenapi v0.36.1
+ github.com/pb33f/libopenapi-validator v0.13.7
github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.23.0
github.com/prometheus/common v0.65.0
@@ -62,7 +63,6 @@ require (
go.opentelemetry.io/otel/sdk/metric v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
go.uber.org/zap v1.21.0
- go.yaml.in/yaml/v4 v4.0.0-rc.2
golang.org/x/crypto v0.49.0
golang.org/x/net v0.52.0
golang.org/x/sync v0.20.0
@@ -74,6 +74,10 @@ require (
v.marlon.life/toolkit v0.0.0-20211025131614-e4a91730b4ab
)
+// Pin v2.0.2 to match github.com/go-openapi/spec v0.22.1 test dependencies
+// pulled through swaggo/swag; newer testify/v2 versions are not compatible
with that test tree.
+replace github.com/go-openapi/testify/v2 => github.com/go-openapi/testify/v2
v2.0.2
+
require (
cel.dev/expr v0.15.0 // indirect
filippo.io/edwards25519 v1.1.1 // indirect
@@ -105,11 +109,12 @@ require (
github.com/aliyun/credentials-go v1.4.3 // indirect
github.com/apolloconfig/agollo/v4 v4.4.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
+ github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad //
indirect
github.com/beevik/etree v1.5.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.2.0 // indirect
github.com/bufbuild/protocompile v0.14.1 // indirect
- github.com/buger/jsonparser v1.1.1 // indirect
+ github.com/buger/jsonparser v1.1.2 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
@@ -123,7 +128,7 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/deckarep/golang-set v1.7.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
- github.com/dlclark/regexp2 v1.7.0 // indirect
+ github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/dop251/goja v0.0.0-20240220182346-e401ed450204 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eapache/go-resiliency v1.7.0 // indirect
@@ -137,11 +142,11 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
- github.com/go-openapi/jsonpointer v0.22.3 // indirect
+ github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/jsonreference v0.21.3 // indirect
github.com/go-openapi/spec v0.22.1 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
- github.com/go-openapi/swag/jsonname v0.25.4 // indirect
+ github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
github.com/go-openapi/swag/loading v0.25.4 // indirect
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
@@ -208,8 +213,8 @@ require (
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/openzipkin/zipkin-go v0.4.2 // indirect
github.com/orcaman/concurrent-map v0.0.0-20210501183033-44dafcb38ecc //
indirect
- github.com/pb33f/jsonpath v0.1.2 // indirect
- github.com/pb33f/ordered-map/v2 v2.3.0 // indirect
+ github.com/pb33f/jsonpath v0.8.2 // indirect
+ github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
github.com/pelletier/go-toml v1.9.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pierrec/lz4 v2.6.1+incompatible // indirect
@@ -224,6 +229,7 @@ require (
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 //
indirect
github.com/russellhaering/goxmldsig v1.4.0 // indirect
+ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/shirou/gopsutil/v3 v3.22.2 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
@@ -258,6 +264,7 @@ require (
go.uber.org/atomic v1.10.0 // indirect
go.uber.org/multierr v1.8.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
+ go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/sys v0.42.0 // indirect
diff --git a/go.sum b/go.sum
index cee292724..5f5ccdb23 100644
--- a/go.sum
+++ b/go.sum
@@ -182,6 +182,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.7.2/go.mod
h1:8EzeIqfWt2wWT4rJVu3f21
github.com/aws/smithy-go v1.8.0/go.mod
h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
github.com/bahlo/generic-list-go v0.2.0
h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod
h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
+github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad
h1:3swAvbzgfaI6nKuDDU7BiKfZRdF+h2ZwKgMHd8Ha4t8=
+github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad/go.mod
h1:9+nBLYNWkvPcq9ep0owWUsPTLgL9ZXTsZWcCSVGGLJ0=
github.com/beevik/etree v1.1.0/go.mod
h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
github.com/beevik/etree v1.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs=
github.com/beevik/etree v1.5.0/go.mod
h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs=
@@ -192,6 +194,8 @@ github.com/beorn7/perks v1.0.0/go.mod
h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+Ce
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod
h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/speakeasy v0.1.0/go.mod
h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
+github.com/bitly/go-simplejson v0.5.1
h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
+github.com/bitly/go-simplejson v0.5.1/go.mod
h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
github.com/bits-and-blooms/bitset v1.2.0
h1:Kn4yilvwNtMACtf1eYDlG8H77R07mZSPbMjLyS07ChA=
github.com/bits-and-blooms/bitset v1.2.0/go.mod
h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA=
github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod
h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
@@ -199,8 +203,9 @@ github.com/bketelsen/crypt v0.0.4/go.mod
h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqO
github.com/bufbuild/protocompile v0.14.1
h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
github.com/bufbuild/protocompile v0.14.1/go.mod
h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod
h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s=
-github.com/buger/jsonparser v1.1.1
h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod
h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
+github.com/buger/jsonparser v1.1.2
h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
+github.com/buger/jsonparser v1.1.2/go.mod
h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bytecodealliance/wasmtime-go v1.0.0
h1:9u9gqaUiaJeN5IoD1L7egD8atOnTGyJcNp8BhkL9cUU=
github.com/bytecodealliance/wasmtime-go v1.0.0/go.mod
h1:jjlqQbWUfVSbehpErw3UoWFndBXRRMvfikYH6KsCwOg=
github.com/bytedance/sonic v1.11.6
h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
@@ -288,8 +293,9 @@ github.com/dgryski/go-sip13
v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48
h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g=
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod
h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod
h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
-github.com/dlclark/regexp2 v1.7.0
h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
github.com/dlclark/regexp2 v1.7.0/go.mod
h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/dlclark/regexp2 v1.12.0
h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
+github.com/dlclark/regexp2 v1.12.0/go.mod
h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod
h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk=
github.com/dop251/goja v0.0.0-20240220182346-e401ed450204
h1:O7I1iuzEA7SG+dK8ocOBSlYAA9jBUmCYl/Qa7ey7JAM=
github.com/dop251/goja v0.0.0-20240220182346-e401ed450204/go.mod
h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4=
@@ -394,8 +400,8 @@ github.com/go-logr/stdr v1.2.2/go.mod
h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
github.com/go-ole/go-ole v1.2.4/go.mod
h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod
h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
-github.com/go-openapi/jsonpointer v0.22.3
h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
-github.com/go-openapi/jsonpointer v0.22.3/go.mod
h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
+github.com/go-openapi/jsonpointer v0.23.1
h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
+github.com/go-openapi/jsonpointer v0.23.1/go.mod
h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
github.com/go-openapi/jsonreference v0.21.3
h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
github.com/go-openapi/jsonreference v0.21.3/go.mod
h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
github.com/go-openapi/spec v0.22.1
h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
@@ -403,8 +409,8 @@ github.com/go-openapi/spec v0.22.1/go.mod
h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONSt
github.com/go-openapi/swag v0.19.15
h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag/conv v0.25.4
h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.4/go.mod
h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
-github.com/go-openapi/swag/jsonname v0.25.4
h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
-github.com/go-openapi/swag/jsonname v0.25.4/go.mod
h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
+github.com/go-openapi/swag/jsonname v0.26.0
h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
+github.com/go-openapi/swag/jsonname v0.26.0/go.mod
h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
github.com/go-openapi/swag/jsonutils v0.25.4
h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod
h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4
h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
@@ -869,12 +875,14 @@ github.com/orcaman/concurrent-map
v0.0.0-20210501183033-44dafcb38ecc/go.mod h1:L
github.com/pact-foundation/pact-go v1.0.4/go.mod
h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod
h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pascaldekloe/goe v0.1.0/go.mod
h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
-github.com/pb33f/jsonpath v0.1.2
h1:PlqXjEyecMqoYJupLxYeClCGWEpAFnh4pmzgspbXDPI=
-github.com/pb33f/jsonpath v0.1.2/go.mod
h1:TtKnUnfqZm48q7a56DxB3WtL3ipkVtukMKGKxaR/uXU=
-github.com/pb33f/libopenapi v0.28.2
h1:AXVCE8DWzytXu0jv0Z+cXVopnO/bXU1oWvgA9qiRWgw=
-github.com/pb33f/libopenapi v0.28.2/go.mod
h1:mHMHA3ZKSZDTInNAuUtqkHlKLIjPm2HN1vgsGR57afc=
-github.com/pb33f/ordered-map/v2 v2.3.0
h1:k2OhVEQkhTCQMhAicQ3Z6iInzoZNQ7L9MVomwKBZ5WQ=
-github.com/pb33f/ordered-map/v2 v2.3.0/go.mod
h1:oe5ue+6ZNhy7QN9cPZvPA23Hx0vMHnNVeMg4fGdCANw=
+github.com/pb33f/jsonpath v0.8.2
h1:Ou4C7zjYClBm97dfZjDCjdZGusJoynv/vrtiEKNfj2Y=
+github.com/pb33f/jsonpath v0.8.2/go.mod
h1:zBV5LJW4OQOPatmQE2QdKpGQJvhDTlE5IEj6ASaRNTo=
+github.com/pb33f/libopenapi v0.36.1
h1:CNZ52e+/W9fA1kAgL8EePDQQrKPfN9+HdLR6XAxUEpw=
+github.com/pb33f/libopenapi v0.36.1/go.mod
h1:MsDdUlQ1CdrIDO5v26JfgBxQs7kcaOUEpMP3EqU6bI4=
+github.com/pb33f/libopenapi-validator v0.13.7
h1:9rMZjvoG9Qp9u2pr//EKl0cM+Qiwve5pY3Os0qoRC0M=
+github.com/pb33f/libopenapi-validator v0.13.7/go.mod
h1:zkbQL07GQANm5UNSvm7Lk5ofDL9GnD4TyCnzHus5lZI=
+github.com/pb33f/ordered-map/v2 v2.3.1
h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
+github.com/pb33f/ordered-map/v2 v2.3.1/go.mod
h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
github.com/pborman/uuid v1.2.0/go.mod
h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
github.com/pelletier/go-toml v1.2.0/go.mod
h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pelletier/go-toml v1.7.0/go.mod
h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
@@ -976,6 +984,8 @@ github.com/ryanuber/columnize
v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb
github.com/ryanuber/columnize v2.1.0+incompatible/go.mod
h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/ryanuber/go-glob v1.0.0/go.mod
h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod
h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod
h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod
h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod
h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
@@ -1213,8 +1223,8 @@ go.uber.org/zap v1.21.0
h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod
h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
-go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod
h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
+go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U=
+go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod
h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod
h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod
h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
@@ -1237,6 +1247,7 @@ golang.org/x/crypto
v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod
h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.6.0/go.mod
h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.10.0/go.mod
h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
+golang.org/x/crypto v0.11.0/go.mod
h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/crypto v0.14.0/go.mod
h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.18.0/go.mod
h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod
h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
@@ -1347,6 +1358,7 @@ golang.org/x/net v0.6.0/go.mod
h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
+golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
@@ -1475,6 +1487,7 @@ golang.org/x/sys v0.5.0/go.mod
h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
@@ -1486,6 +1499,7 @@ golang.org/x/term
v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo=
+golang.org/x/term v0.10.0/go.mod
h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
golang.org/x/term v0.13.0/go.mod
h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.16.0/go.mod
h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod
h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
@@ -1504,6 +1518,7 @@ golang.org/x/text v0.3.8/go.mod
h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.10.0/go.mod
h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.11.0/go.mod
h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0/go.mod
h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod
h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
diff --git a/pkg/common/constant/key.go b/pkg/common/constant/key.go
index b69cc5f57..721361481 100644
--- a/pkg/common/constant/key.go
+++ b/pkg/common/constant/key.go
@@ -37,6 +37,7 @@ const (
HTTPDubboProxyFilter = "dgp.filter.http.dubboproxy"
HTTPDirectDubboProxyFilter = "dgp.filter.http.directdubboproxy"
HTTPApiConfigFilter = "dgp.filter.http.apiconfig"
+ HTTPOpenAPIFilter = "dgp.filter.http.openapi"
HTTPTimeoutFilter = "dgp.filter.http.timeout"
TracingFilter = "dgp.filters.tracing"
HTTPWasmFilter = "dgp.filter.http.webassembly"
diff --git a/pkg/context/http/erroresponse.go b/pkg/context/http/erroresponse.go
index 97587b625..70aeb3b02 100644
--- a/pkg/context/http/erroresponse.go
+++ b/pkg/context/http/erroresponse.go
@@ -61,6 +61,9 @@ var (
// 429 - Rate Limited
RateLimited = newErrorBuilder(http.StatusTooManyRequests, "Rate
limited")
+ // 413 - Payload Too Large
+ PayloadTooLarge = newErrorBuilder(http.StatusRequestEntityTooLarge,
"Payload too large")
+
// 500 - Internal Server Error
InternalError = newErrorBuilder(http.StatusInternalServerError,
"Internal server error")
ConfigurationError = newErrorBuilder(http.StatusInternalServerError,
"Configuration error")
diff --git a/pkg/filter/http/apiconfig/api_config.go
b/pkg/filter/http/apiconfig/api_config.go
index e48d72ba5..7918e673a 100644
--- a/pkg/filter/http/apiconfig/api_config.go
+++ b/pkg/filter/http/apiconfig/api_config.go
@@ -17,10 +17,6 @@
package apiconfig
-import (
- "os"
-)
-
import (
"github.com/pkg/errors"
)
@@ -31,7 +27,6 @@ import (
"github.com/apache/dubbo-go-pixiu/pkg/config"
contexthttp "github.com/apache/dubbo-go-pixiu/pkg/context/http"
"github.com/apache/dubbo-go-pixiu/pkg/filter/http/apiconfig/api"
- "github.com/apache/dubbo-go-pixiu/pkg/filter/http/apiconfig/openapi"
"github.com/apache/dubbo-go-pixiu/pkg/logger"
"github.com/apache/dubbo-go-pixiu/pkg/router"
"github.com/apache/dubbo-go-pixiu/pkg/server"
@@ -74,8 +69,8 @@ func (factory *FilterFactory) Config() any {
func (factory *FilterFactory) Apply() error {
factory.apiService = api.NewLocalMemoryAPIDiscoveryService()
- if factory.cfg.Dynamic && (factory.cfg.EnableOpenAPIValidation ||
factory.cfg.OpenAPIPath != "") {
- return errors.New("dynamic api config does not support openapi
validation")
+ if factory.cfg.hasDeprecatedOpenAPIConfig() {
+ return errors.New("openapi_path and enable_openapi_validation
have moved out of apiconfig; configure dgp.filter.http.openapi instead")
}
if factory.cfg.Dynamic {
@@ -84,9 +79,6 @@ func (factory *FilterFactory) Apply() error {
}
if factory.cfg.Path == "" && factory.cfg.APIMetaConfig == nil {
- if factory.cfg.EnableOpenAPIValidation &&
factory.cfg.OpenAPIPath != "" {
- logger.Warn("openapi validation is configured without
api config; skip openapi validation")
- }
return nil
}
@@ -98,12 +90,6 @@ func (factory *FilterFactory) Apply() error {
return err
}
- if factory.cfg.EnableOpenAPIValidation && factory.cfg.OpenAPIPath != ""
{
- if err :=
factory.mergeOpenAPIFromFile(factory.cfg.OpenAPIPath); err != nil {
- return err
- }
- }
-
return nil
}
@@ -147,14 +133,6 @@ func (f *Filter) Decode(ctx *contexthttp.HttpContext)
filter.FilterStatus {
return filter.Stop
}
- if plan := openapi.ExtractValidationPlan(v); plan != nil {
- if err := openapi.ValidateRequest(req, plan); err != nil {
- errResp := contexthttp.BadRequest.WithError(err)
- ctx.SendLocalReply(errResp.Status, errResp.ToJSON())
- logger.Debug(errResp.Error())
- return filter.Stop
- }
- }
ctx.API(v)
return filter.Continue
}
@@ -163,37 +141,6 @@ func (factory *FilterFactory) GetApiService()
api.APIDiscoveryService {
return factory.apiService
}
-func (factory *FilterFactory) mergeOpenAPIFromFile(path string) error {
- spec, err := os.ReadFile(path)
- if err != nil {
- return err
- }
-
- doc, err := openapi.LoadDocumentFromBytes(spec)
- if err != nil {
- return err
- }
-
- compiledRoutes, err := openapi.CompileRoutes(doc)
- if err != nil {
- return err
- }
- for _, compiled := range compiledRoutes {
- if _, err :=
factory.apiService.GetAPI(compiled.Route.URLPattern, compiled.Route.HTTPVerb);
err != nil {
- logger.Warnf(
- "skip openapi validation for %s %s because api
config route does not exist",
- compiled.Route.HTTPVerb,
- compiled.Route.URLPattern,
- )
- continue
- }
- if err := factory.apiService.MergeAPI(compiled.Route); err !=
nil {
- return err
- }
- }
- return nil
-}
-
// initApiConfig return value of the bool is for the judgment of whether is a
api meta data error, a kind of silly (?)
func initApiConfig(cf *ApiConfigConfig) (*config.APIConfig, error) {
if cf.APIMetaConfig != nil {
diff --git a/pkg/filter/http/apiconfig/api_config_test.go
b/pkg/filter/http/apiconfig/api_config_test.go
index ff6b46926..1942a130d 100644
--- a/pkg/filter/http/apiconfig/api_config_test.go
+++ b/pkg/filter/http/apiconfig/api_config_test.go
@@ -21,169 +21,97 @@ import (
"net/http"
"net/http/httptest"
"os"
+ "path/filepath"
"testing"
)
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+
+ "gopkg.in/yaml.v3"
)
import (
- "github.com/apache/dubbo-go-pixiu/pkg/common/constant"
extfilter "github.com/apache/dubbo-go-pixiu/pkg/common/extension/filter"
- "github.com/apache/dubbo-go-pixiu/pkg/config"
contexthttp "github.com/apache/dubbo-go-pixiu/pkg/context/http"
- "github.com/apache/dubbo-go-pixiu/pkg/filter/http/apiconfig/api"
- "github.com/apache/dubbo-go-pixiu/pkg/filter/http/apiconfig/openapi"
- "github.com/apache/dubbo-go-pixiu/pkg/router"
)
-func TestDecode_StopsOnOpenAPIValidationFailure(t *testing.T) {
- apiService := api.NewLocalMemoryAPIDiscoveryService()
- err := apiService.AddAPI(router.API{
- URLPattern: "/users",
- Method: config.Method{
- Enable: true,
- HTTPVerb: constant.Get,
- },
- Metadata: map[string]any{
- openapi.ValidationPlanMetadataKey:
&openapi.ValidationPlan{
- QueryParameters: []openapi.ParameterValidation{
- {Name: "id", Required: true},
- },
- },
- },
- })
- require.NoError(t, err)
+func TestDecode_ContinuesWhenRouteMatches(t *testing.T) {
+ factory := newAPIConfigFactory(t, `
+name: api name
+resources:
+ - path: /users
+ type: restful
+ methods:
+ - httpVerb: GET
+ enable: true
+`)
- filterInstance := &Filter{apiService: apiService}
+ filterInstance := &Filter{apiService: factory.apiService}
req := httptest.NewRequest(http.MethodGet, "/users", nil)
recorder := httptest.NewRecorder()
ctx := &contexthttp.HttpContext{Request: req, Writer: recorder}
status := filterInstance.Decode(ctx)
- assert.Equal(t, extfilter.Stop, status)
- assert.Equal(t, http.StatusBadRequest, recorder.Code)
- assert.True(t, ctx.LocalReply())
- assert.Nil(t, ctx.GetAPI())
+ assert.Equal(t, extfilter.Continue, status)
+ require.NotNil(t, ctx.GetAPI())
+ assert.Equal(t, "/users", ctx.GetAPI().URLPattern)
+ assert.Equal(t, http.MethodGet, ctx.GetAPI().HTTPVerb)
+ assert.False(t, ctx.LocalReply())
}
-func TestDecode_ContinuesOnOpenAPIValidationSuccess(t *testing.T) {
- apiService := api.NewLocalMemoryAPIDiscoveryService()
- err := apiService.AddAPI(router.API{
- URLPattern: "/users",
- Method: config.Method{
- Enable: true,
- HTTPVerb: constant.Get,
- },
- Metadata: map[string]any{
- openapi.ValidationPlanMetadataKey:
&openapi.ValidationPlan{
- QueryParameters: []openapi.ParameterValidation{
- {Name: "id", Required: true},
- },
- },
- },
- })
- require.NoError(t, err)
+func TestDecode_StopsWhenRouteIsMissing(t *testing.T) {
+ factory := newAPIConfigFactory(t, `
+name: api name
+resources:
+ - path: /users
+ type: restful
+ methods:
+ - httpVerb: GET
+ enable: true
+`)
- filterInstance := &Filter{apiService: apiService}
- req := httptest.NewRequest(http.MethodGet, "/users?id=123", nil)
+ filterInstance := &Filter{apiService: factory.apiService}
+ req := httptest.NewRequest(http.MethodGet, "/orders", nil)
recorder := httptest.NewRecorder()
ctx := &contexthttp.HttpContext{Request: req, Writer: recorder}
status := filterInstance.Decode(ctx)
- assert.Equal(t, extfilter.Continue, status)
- require.NotNil(t, ctx.GetAPI())
- assert.Equal(t, "/users", ctx.GetAPI().URLPattern)
+ assert.Equal(t, extfilter.Stop, status)
+ assert.Equal(t, http.StatusNotFound, recorder.Code)
+ assert.True(t, ctx.LocalReply())
+ assert.Nil(t, ctx.GetAPI())
}
-func TestDecode_StopsOnOpenAPIParameterTypeFailure(t *testing.T) {
- apiService := api.NewLocalMemoryAPIDiscoveryService()
- err := apiService.AddAPI(router.API{
- URLPattern: "/users",
- Method: config.Method{
- Enable: true,
- HTTPVerb: constant.Get,
- },
- Metadata: map[string]any{
- openapi.ValidationPlanMetadataKey:
&openapi.ValidationPlan{
- QueryParameters: []openapi.ParameterValidation{
- {Name: "page", Type: "integer",
Required: true},
- },
- },
- },
- })
- require.NoError(t, err)
+func TestDecode_StopsWhenRouteIsDisabled(t *testing.T) {
+ factory := newAPIConfigFactory(t, `
+name: api name
+resources:
+ - path: /users
+ type: restful
+ methods:
+ - httpVerb: GET
+ enable: false
+`)
- filterInstance := &Filter{apiService: apiService}
- req := httptest.NewRequest(http.MethodGet, "/users?page=abc", nil)
+ filterInstance := &Filter{apiService: factory.apiService}
+ req := httptest.NewRequest(http.MethodGet, "/users", nil)
recorder := httptest.NewRecorder()
ctx := &contexthttp.HttpContext{Request: req, Writer: recorder}
status := filterInstance.Decode(ctx)
assert.Equal(t, extfilter.Stop, status)
- assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ assert.Equal(t, http.StatusNotAcceptable, recorder.Code)
assert.True(t, ctx.LocalReply())
assert.Nil(t, ctx.GetAPI())
}
-func TestApply_MergesOpenAPIRoutesFromFile(t *testing.T) {
- specFile, err := os.CreateTemp(t.TempDir(), "openapi-*.yaml")
- require.NoError(t, err)
- defer specFile.Close()
-
- _, err = specFile.WriteString(`
-openapi: 3.0.3
-info:
- title: users
- version: "1.0.0"
-paths:
- /users/{id}:
- get:
- parameters:
- - name: id
- in: path
- required: true
- schema:
- type: string
- responses:
- "200":
- description: ok
- /users:
- post:
- parameters:
- - name: source
- in: query
- required: true
- schema:
- type: string
- enum: [web, app]
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required: [name]
- properties:
- name:
- type: string
- maxLength: 32
- responses:
- "200":
- description: ok
-`)
- require.NoError(t, err)
-
- apiConfigFile, err := os.CreateTemp(t.TempDir(), "api-config-*.yaml")
- require.NoError(t, err)
- defer apiConfigFile.Close()
-
- _, err = apiConfigFile.WriteString(`
+func TestApply_KeepsNestedRouteMatching(t *testing.T) {
+ factory := newAPIConfigFactory(t, `
name: api name
resources:
- path: /users
@@ -198,43 +126,80 @@ resources:
- httpVerb: GET
enable: true
`)
- require.NoError(t, err)
-
- factory := &FilterFactory{
- cfg: &ApiConfigConfig{
- Path: apiConfigFile.Name(),
- OpenAPIPath: specFile.Name(),
- EnableOpenAPIValidation: true,
- },
- }
-
- err = factory.Apply()
- require.NoError(t, err)
matched, err := factory.apiService.MatchAPI("/users", http.MethodPost)
require.NoError(t, err)
- require.NotNil(t, openapi.ExtractValidationPlan(matched))
+ require.NotNil(t, matched)
assert.Equal(t, "/users", matched.URLPattern)
assert.Equal(t, http.MethodPost, matched.HTTPVerb)
pathMatched, err := factory.apiService.MatchAPI("/users/42",
http.MethodGet)
require.NoError(t, err)
- require.NotNil(t, openapi.ExtractValidationPlan(pathMatched))
+ require.NotNil(t, pathMatched)
assert.Equal(t, "/users/:id", pathMatched.URLPattern)
}
-func TestApply_RejectsDynamicOpenAPIValidationCombination(t *testing.T) {
+func TestApply_RejectsDeprecatedOpenAPIConfig(t *testing.T) {
factory := &FilterFactory{
cfg: &ApiConfigConfig{
- Dynamic: true,
- DynamicAdapter: "mock",
- OpenAPIPath: "configs/openapi_users.yaml",
+ OpenAPIPath: "configs/openapi.yaml",
EnableOpenAPIValidation: true,
},
}
err := factory.Apply()
+
require.Error(t, err)
- assert.ErrorContains(t, err, "dynamic")
- assert.ErrorContains(t, err, "openapi")
+ assert.Contains(t, err.Error(), "dgp.filter.http.openapi")
+}
+
+func TestApply_RejectsDeprecatedOpenAPIConfigPresence(t *testing.T) {
+ tests := []struct {
+ name string
+ yaml string
+ }{
+ {
+ name: "empty openapi path",
+ yaml: `
+path: configs/api_config.yaml
+openapi_path: ""
+`,
+ },
+ {
+ name: "disabled openapi validation",
+ yaml: `
+path: configs/api_config.yaml
+enable_openapi_validation: false
+`,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := &ApiConfigConfig{}
+ require.NoError(t, yaml.Unmarshal([]byte(tt.yaml), cfg))
+ factory := &FilterFactory{cfg: cfg}
+
+ err := factory.Apply()
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(),
"dgp.filter.http.openapi")
+ })
+ }
+}
+
+func newAPIConfigFactory(t *testing.T, apiConfig string) *FilterFactory {
+ t.Helper()
+
+ dir := t.TempDir()
+ apiConfigPath := filepath.Join(dir, "api-config.yaml")
+ require.NoError(t, os.WriteFile(apiConfigPath, []byte(apiConfig),
0o600))
+
+ factory := &FilterFactory{
+ cfg: &ApiConfigConfig{
+ Path: apiConfigPath,
+ },
+ }
+ require.NoError(t, factory.Apply())
+ return factory
}
diff --git a/pkg/filter/http/apiconfig/config.go
b/pkg/filter/http/apiconfig/config.go
index f7f2513e7..d5178a521 100644
--- a/pkg/filter/http/apiconfig/config.go
+++ b/pkg/filter/http/apiconfig/config.go
@@ -17,6 +17,10 @@
package apiconfig
+import (
+ "gopkg.in/yaml.v3"
+)
+
import (
"github.com/apache/dubbo-go-pixiu/pkg/model"
)
@@ -29,4 +33,37 @@ type ApiConfigConfig struct {
DynamicAdapter string `yaml:"dynamic_adapter"
json:"dynamic_adapter,omitempty"`
OpenAPIPath string `yaml:"openapi_path"
json:"openapi_path,omitempty"`
EnableOpenAPIValidation bool
`yaml:"enable_openapi_validation" json:"enable_openapi_validation,omitempty"`
+
+ deprecatedOpenAPIPathSet bool
+ deprecatedEnableOpenAPIValidationSet bool
+}
+
+func (c *ApiConfigConfig) UnmarshalYAML(value *yaml.Node) error {
+ type rawConfig ApiConfigConfig
+ var decoded rawConfig
+ if err := value.Decode(&decoded); err != nil {
+ return err
+ }
+
+ *c = ApiConfigConfig(decoded)
+ if value.Kind != yaml.MappingNode {
+ return nil
+ }
+
+ for i := 0; i+1 < len(value.Content); i += 2 {
+ switch value.Content[i].Value {
+ case "openapi_path":
+ c.deprecatedOpenAPIPathSet = true
+ case "enable_openapi_validation":
+ c.deprecatedEnableOpenAPIValidationSet = true
+ }
+ }
+ return nil
+}
+
+func (c *ApiConfigConfig) hasDeprecatedOpenAPIConfig() bool {
+ return c.OpenAPIPath != "" ||
+ c.EnableOpenAPIValidation ||
+ c.deprecatedOpenAPIPathSet ||
+ c.deprecatedEnableOpenAPIValidationSet
}
diff --git a/pkg/filter/http/apiconfig/openapi/compiler.go
b/pkg/filter/http/apiconfig/openapi/compiler.go
deleted file mode 100644
index 646f8a065..000000000
--- a/pkg/filter/http/apiconfig/openapi/compiler.go
+++ /dev/null
@@ -1,284 +0,0 @@
-/*
- * 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.
- */
-
-package openapi
-
-import (
- "fmt"
- "regexp"
- "strings"
-)
-
-import (
- "github.com/pb33f/libopenapi"
- "github.com/pb33f/libopenapi/datamodel/high/base"
- v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
-
- "go.yaml.in/yaml/v4"
-)
-
-import (
- "github.com/apache/dubbo-go-pixiu/pkg/config"
- "github.com/apache/dubbo-go-pixiu/pkg/router"
-)
-
-var openAPIPathParamPattern = regexp.MustCompile(`\{([^}/]+)\}`)
-
-type CompiledRoute struct {
- Route router.API
- Validation *ValidationPlan
-}
-
-func LoadDocumentFromBytes(spec []byte) (libopenapi.Document, error) {
- return libopenapi.NewDocument(spec)
-}
-
-func CompileRoutes(doc libopenapi.Document) ([]CompiledRoute, error) {
- model, err := doc.BuildV3Model()
- if err != nil {
- return nil, err
- }
- if model == nil || model.Model.Paths == nil {
- return nil, fmt.Errorf("openapi document does not contain
paths")
- }
-
- var compiled []CompiledRoute
- for path, item := range model.Model.Paths.PathItems.FromOldest() {
- compiled = appendCompiledRoute(compiled, path,
config.Method{Enable: true, HTTPVerb: "GET"}, item, item.Get)
- compiled = appendCompiledRoute(compiled, path,
config.Method{Enable: true, HTTPVerb: "PUT"}, item, item.Put)
- compiled = appendCompiledRoute(compiled, path,
config.Method{Enable: true, HTTPVerb: "POST"}, item, item.Post)
- compiled = appendCompiledRoute(compiled, path,
config.Method{Enable: true, HTTPVerb: "DELETE"}, item, item.Delete)
- compiled = appendCompiledRoute(compiled, path,
config.Method{Enable: true, HTTPVerb: "PATCH"}, item, item.Patch)
- compiled = appendCompiledRoute(compiled, path,
config.Method{Enable: true, HTTPVerb: "HEAD"}, item, item.Head)
- compiled = appendCompiledRoute(compiled, path,
config.Method{Enable: true, HTTPVerb: "OPTIONS"}, item, item.Options)
- compiled = appendCompiledRoute(compiled, path,
config.Method{Enable: true, HTTPVerb: "TRACE"}, item, item.Trace)
- }
-
- return compiled, nil
-}
-
-func appendCompiledRoute(compiled []CompiledRoute, path string, method
config.Method, pathItem *v3.PathItem, operation *v3.Operation) []CompiledRoute {
- if operation == nil {
- return compiled
- }
-
- validationPlan := compileValidationPlan(path, pathItem, operation)
-
- return append(compiled, CompiledRoute{
- Route: router.API{
- URLPattern: normalizeOpenAPIPath(path),
- Method: method,
- Metadata: map[string]any{
- ValidationPlanMetadataKey: validationPlan,
- },
- },
- Validation: validationPlan,
- })
-}
-
-func compileValidationPlan(path string, pathItem *v3.PathItem, operation
*v3.Operation) *ValidationPlan {
- plan := &ValidationPlan{}
- if operation == nil {
- return plan
- }
- plan.RoutePattern = normalizeOpenAPIPath(path)
-
- for _, parameter := range mergedParameters(pathItem, operation) {
- if parameter == nil {
- continue
- }
- compiled := compileParameter(parameter)
- switch strings.ToLower(parameter.In) {
- case "path":
- plan.PathParameters = append(plan.PathParameters,
compiled)
- case "query":
- plan.QueryParameters = append(plan.QueryParameters,
compiled)
- case "header":
- plan.HeaderParameters = append(plan.HeaderParameters,
compiled)
- }
- }
-
- plan.RequestBody = compileRequestBody(operation.RequestBody)
- return plan
-}
-
-func mergedParameters(pathItem *v3.PathItem, operation *v3.Operation)
[]*v3.Parameter {
- var parameters []*v3.Parameter
- if pathItem != nil {
- parameters = append(parameters, pathItem.Parameters...)
- }
- if operation == nil {
- return parameters
- }
-
- for _, parameter := range operation.Parameters {
- if parameter == nil {
- parameters = append(parameters, nil)
- continue
- }
- replaced := false
- for idx, existing := range parameters {
- if existing == nil {
- continue
- }
- if strings.EqualFold(existing.Name, parameter.Name) &&
strings.EqualFold(existing.In, parameter.In) {
- parameters[idx] = parameter
- replaced = true
- break
- }
- }
- if !replaced {
- parameters = append(parameters, parameter)
- }
- }
-
- return parameters
-}
-
-func compileParameter(parameter *v3.Parameter) ParameterValidation {
- compiled := ParameterValidation{
- Name: parameter.Name,
- }
- if parameter.Required != nil {
- compiled.Required = *parameter.Required
- }
- if parameter.Schema != nil {
- schema, err := parameter.Schema.BuildSchema()
- if err == nil && schema != nil {
- compiled.Type = firstType(schema)
- compiled.Enum = decodeEnum(schema.Enum)
- if schema.MinLength != nil {
- value := int(*schema.MinLength)
- compiled.MinLength = &value
- }
- if schema.MaxLength != nil {
- value := int(*schema.MaxLength)
- compiled.MaxLength = &value
- }
- if schema.Minimum != nil {
- value := *schema.Minimum
- compiled.Minimum = &value
- }
- if schema.Maximum != nil {
- value := *schema.Maximum
- compiled.Maximum = &value
- }
- }
- }
- return compiled
-}
-
-func compileRequestBody(body *v3.RequestBody) *BodyValidation {
- if body == nil {
- return nil
- }
-
- compiled := &BodyValidation{}
- if body.Required != nil {
- compiled.Required = *body.Required
- }
- if body.Content == nil {
- return compiled
- }
-
- mediaType := body.Content.GetOrZero("application/json")
- if mediaType == nil || mediaType.Schema == nil {
- return compiled
- }
-
- schema, err := mediaType.Schema.BuildSchema()
- if err != nil || schema == nil {
- return compiled
- }
- compiled.Schema = compileSchema(schema)
- return compiled
-}
-
-func compileSchema(schema *base.Schema) *SchemaValidation {
- if schema == nil {
- return nil
- }
-
- compiled := &SchemaValidation{
- Type: firstType(schema),
- Required: append([]string(nil), schema.Required...),
- Enum: decodeEnum(schema.Enum),
- }
-
- if schema.MinLength != nil {
- value := int(*schema.MinLength)
- compiled.MinLength = &value
- }
- if schema.MaxLength != nil {
- value := int(*schema.MaxLength)
- compiled.MaxLength = &value
- }
- if schema.Minimum != nil {
- value := *schema.Minimum
- compiled.Minimum = &value
- }
- if schema.Maximum != nil {
- value := *schema.Maximum
- compiled.Maximum = &value
- }
- if schema.Properties != nil {
- compiled.Properties = make(map[string]*SchemaValidation,
schema.Properties.Len())
- for name, property := range schema.Properties.FromOldest() {
- if property == nil {
- continue
- }
- propertySchema, err := property.BuildSchema()
- if err != nil || propertySchema == nil {
- continue
- }
- compiled.Properties[name] =
compileSchema(propertySchema)
- }
- }
-
- return compiled
-}
-
-func firstType(schema *base.Schema) string {
- if schema == nil || len(schema.Type) == 0 {
- return ""
- }
- return schema.Type[0]
-}
-
-func normalizeOpenAPIPath(path string) string {
- return openAPIPathParamPattern.ReplaceAllString(path, `:$1`)
-}
-
-func decodeEnum(values []*yaml.Node) []string {
- if len(values) == 0 {
- return nil
- }
-
- result := make([]string, 0, len(values))
- for _, value := range values {
- if value == nil {
- continue
- }
- var decoded string
- if err := value.Decode(&decoded); err == nil {
- result = append(result, decoded)
- continue
- }
- result = append(result, value.Value)
- }
- return result
-}
diff --git a/pkg/filter/http/apiconfig/openapi/compiler_test.go
b/pkg/filter/http/apiconfig/openapi/compiler_test.go
deleted file mode 100644
index e43a51080..000000000
--- a/pkg/filter/http/apiconfig/openapi/compiler_test.go
+++ /dev/null
@@ -1,203 +0,0 @@
-/*
- * 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.
- */
-
-package openapi
-
-import (
- "net/http"
- "testing"
-)
-
-import (
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-const minimalOpenAPISpec = `
-openapi: 3.0.3
-info:
- title: users
- version: "1.0.0"
-paths:
- /users/{id}:
- get:
- parameters:
- - name: id
- in: path
- required: true
- schema:
- type: string
- - name: trace-id
- in: header
- required: true
- schema:
- type: string
- responses:
- "200":
- description: ok
- /users:
- post:
- parameters:
- - name: source
- in: query
- required: true
- schema:
- type: string
- enum:
- - web
- - app
- - name: page
- in: query
- required: true
- schema:
- type: integer
- minimum: 1
- maximum: 100
- - name: x-trace
- in: header
- required: true
- schema:
- type: string
- minLength: 4
- maxLength: 12
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - name
- - role
- properties:
- name:
- type: string
- maxLength: 32
- role:
- type: string
- enum:
- - admin
- - member
- responses:
- "200":
- description: ok
-`
-
-const pathLevelParametersSpec = `
-openapi: 3.0.3
-info:
- title: users
- version: "1.0.0"
-paths:
- /users/{id}:
- parameters:
- - name: id
- in: path
- required: true
- schema:
- type: string
- - name: trace-id
- in: header
- required: true
- schema:
- type: string
- get:
- responses:
- "200":
- description: ok
-`
-
-func TestCompileOpenAPIRoute(t *testing.T) {
- doc, err := LoadDocumentFromBytes([]byte(minimalOpenAPISpec))
- require.NoError(t, err)
-
- compiled, err := CompileRoutes(doc)
- require.NoError(t, err)
- require.Len(t, compiled, 2)
-
- compiledByMethod := map[string]CompiledRoute{}
- for _, item := range compiled {
- compiledByMethod[item.Route.HTTPVerb+" "+item.Route.URLPattern]
= item
- }
-
- getRoute, ok := compiledByMethod[http.MethodGet+" /users/:id"]
- require.True(t, ok)
- assert.Equal(t, "/users/:id", getRoute.Route.URLPattern)
- require.NotNil(t, getRoute.Validation)
- assert.Equal(t, "/users/:id", getRoute.Validation.RoutePattern)
- require.Len(t, getRoute.Validation.PathParameters, 1)
- assert.Equal(t, "id", getRoute.Validation.PathParameters[0].Name)
- require.Len(t, getRoute.Validation.HeaderParameters, 1)
- assert.Equal(t, "trace-id",
getRoute.Validation.HeaderParameters[0].Name)
- assert.True(t, getRoute.Validation.HeaderParameters[0].Required)
-
- postRoute, ok := compiledByMethod[http.MethodPost+" /users"]
- require.True(t, ok)
- assert.Equal(t, "/users", postRoute.Route.URLPattern)
- assert.NotNil(t, postRoute.Validation)
- require.Len(t, postRoute.Validation.QueryParameters, 2)
- assert.Equal(t, "source", postRoute.Validation.QueryParameters[0].Name)
- assert.True(t, postRoute.Validation.QueryParameters[0].Required)
- assert.Equal(t, []string{"web", "app"},
postRoute.Validation.QueryParameters[0].Enum)
- pageParam := postRoute.Validation.QueryParameters[1]
- assert.Equal(t, "page", pageParam.Name)
- assert.Equal(t, "integer", pageParam.Type)
- require.NotNil(t, pageParam.Minimum)
- assert.Equal(t, 1.0, *pageParam.Minimum)
- require.NotNil(t, pageParam.Maximum)
- assert.Equal(t, 100.0, *pageParam.Maximum)
-
- require.Len(t, postRoute.Validation.HeaderParameters, 1)
- headerParam := postRoute.Validation.HeaderParameters[0]
- assert.Equal(t, "x-trace", headerParam.Name)
- assert.Equal(t, "string", headerParam.Type)
- require.NotNil(t, headerParam.MinLength)
- assert.Equal(t, 4, *headerParam.MinLength)
- require.NotNil(t, headerParam.MaxLength)
- assert.Equal(t, 12, *headerParam.MaxLength)
-
- require.NotNil(t, postRoute.Validation.RequestBody)
- assert.True(t, postRoute.Validation.RequestBody.Required)
- require.NotNil(t, postRoute.Validation.RequestBody.Schema)
- assert.Equal(t, "object", postRoute.Validation.RequestBody.Schema.Type)
- assert.Equal(t, []string{"name", "role"},
postRoute.Validation.RequestBody.Schema.Required)
- require.Contains(t, postRoute.Validation.RequestBody.Schema.Properties,
"name")
- require.Contains(t, postRoute.Validation.RequestBody.Schema.Properties,
"role")
- require.NotNil(t,
postRoute.Validation.RequestBody.Schema.Properties["name"].MaxLength)
- assert.Equal(t, 32,
*postRoute.Validation.RequestBody.Schema.Properties["name"].MaxLength)
- assert.Equal(t, []string{"admin", "member"},
postRoute.Validation.RequestBody.Schema.Properties["role"].Enum)
-}
-
-func TestCompileOpenAPIRoute_UsesPathLevelParameters(t *testing.T) {
- doc, err := LoadDocumentFromBytes([]byte(pathLevelParametersSpec))
- require.NoError(t, err)
-
- compiled, err := CompileRoutes(doc)
- require.NoError(t, err)
- require.Len(t, compiled, 1)
-
- getRoute := compiled[0]
- assert.Equal(t, http.MethodGet, getRoute.Route.HTTPVerb)
- assert.Equal(t, "/users/:id", getRoute.Route.URLPattern)
- require.NotNil(t, getRoute.Validation)
- require.Len(t, getRoute.Validation.PathParameters, 1)
- assert.Equal(t, "id", getRoute.Validation.PathParameters[0].Name)
- assert.True(t, getRoute.Validation.PathParameters[0].Required)
- require.Len(t, getRoute.Validation.HeaderParameters, 1)
- assert.Equal(t, "trace-id",
getRoute.Validation.HeaderParameters[0].Name)
- assert.True(t, getRoute.Validation.HeaderParameters[0].Required)
-}
diff --git a/pkg/filter/http/apiconfig/openapi/doc.go
b/pkg/filter/http/apiconfig/openapi/doc.go
deleted file mode 100644
index b9bc011fc..000000000
--- a/pkg/filter/http/apiconfig/openapi/doc.go
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * 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.
- */
-
-// Package openapi provides OpenAPI parsing and compilation helpers for the
-// api config filter.
-package openapi
diff --git a/pkg/filter/http/apiconfig/openapi/metadata.go
b/pkg/filter/http/apiconfig/openapi/metadata.go
deleted file mode 100644
index 8e62c8d85..000000000
--- a/pkg/filter/http/apiconfig/openapi/metadata.go
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * 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.
- */
-
-package openapi
-
-import (
- "github.com/apache/dubbo-go-pixiu/pkg/router"
-)
-
-func ExtractValidationPlan(route router.API) *ValidationPlan {
- if route.Metadata == nil {
- return nil
- }
- raw, ok := route.Metadata[ValidationPlanMetadataKey]
- if !ok {
- return nil
- }
- plan, ok := raw.(*ValidationPlan)
- if !ok {
- return nil
- }
- return plan
-}
diff --git a/pkg/filter/http/apiconfig/openapi/model.go
b/pkg/filter/http/apiconfig/openapi/model.go
deleted file mode 100644
index afbf62a18..000000000
--- a/pkg/filter/http/apiconfig/openapi/model.go
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * 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.
- */
-
-package openapi
-
-const ValidationPlanMetadataKey = "openapi.validation.plan"
-
-type ValidationPlan struct {
- RoutePattern string
- PathParameters []ParameterValidation
- QueryParameters []ParameterValidation
- HeaderParameters []ParameterValidation
- RequestBody *BodyValidation
-}
-
-type ParameterValidation struct {
- Name string
- Required bool
- Enum []string
- Type string
- MinLength *int
- MaxLength *int
- Minimum *float64
- Maximum *float64
-}
-
-type BodyValidation struct {
- Required bool
- Schema *SchemaValidation
-}
-
-type SchemaValidation struct {
- Type string
- Required []string
- Properties map[string]*SchemaValidation
- Enum []string
- MinLength *int
- MaxLength *int
- Minimum *float64
- Maximum *float64
-}
diff --git a/pkg/filter/http/apiconfig/openapi/validator.go
b/pkg/filter/http/apiconfig/openapi/validator.go
deleted file mode 100644
index 5296ce777..000000000
--- a/pkg/filter/http/apiconfig/openapi/validator.go
+++ /dev/null
@@ -1,292 +0,0 @@
-/*
- * 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.
- */
-
-package openapi
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "io"
- "math"
- "net/http"
- "strconv"
- "strings"
-)
-
-import (
- "github.com/apache/dubbo-go-pixiu/pkg/router"
-)
-
-func ValidateRequest(req *http.Request, plan *ValidationPlan) error {
- if req == nil || plan == nil {
- return nil
- }
-
- if err := validatePathParameters(req, plan); err != nil {
- return err
- }
-
- if err := validateQueryParameters(req, plan.QueryParameters); err !=
nil {
- return err
- }
-
- if err := validateHeaderParameters(req, plan.HeaderParameters); err !=
nil {
- return err
- }
-
- if err := validateRequestBody(req, plan.RequestBody); err != nil {
- return err
- }
-
- return nil
-}
-
-func validatePathParameters(req *http.Request, plan *ValidationPlan) error {
- if plan.RoutePattern == "" || len(plan.PathParameters) == 0 {
- return nil
- }
-
- values := router.GetURIParams(&router.API{URLPattern:
plan.RoutePattern}, *req.URL)
- for _, param := range plan.PathParameters {
- value := values.Get(param.Name)
- if param.Required && strings.TrimSpace(value) == "" {
- return fmt.Errorf("path parameter %q is required",
param.Name)
- }
- if value == "" {
- continue
- }
- if err := validateParameterValue("path parameter", value,
param); err != nil {
- return err
- }
- }
-
- return nil
-}
-
-func validateQueryParameters(req *http.Request, params []ParameterValidation)
error {
- query := req.URL.Query()
- for _, param := range params {
- value := query.Get(param.Name)
- if param.Required && strings.TrimSpace(value) == "" {
- return fmt.Errorf("query parameter %q is required",
param.Name)
- }
- if value == "" {
- continue
- }
- if err := validateParameterValue("query parameter", value,
param); err != nil {
- return err
- }
- }
- return nil
-}
-
-func validateHeaderParameters(req *http.Request, params []ParameterValidation)
error {
- for _, param := range params {
- value := req.Header.Get(param.Name)
- if param.Required && strings.TrimSpace(value) == "" {
- return fmt.Errorf("header %q is required", param.Name)
- }
- if value == "" {
- continue
- }
- if err := validateParameterValue("header", value, param); err
!= nil {
- return err
- }
- }
- return nil
-}
-
-func validateParameterValue(kind string, value string, param
ParameterValidation) error {
- if len(param.Enum) > 0 && !contains(param.Enum, value) {
- return fmt.Errorf("%s %q must be one of %v", kind, param.Name,
param.Enum)
- }
-
- switch param.Type {
- case "", "string":
- if param.MinLength != nil && len(value) < *param.MinLength {
- return fmt.Errorf("%s %q length must be >= %d", kind,
param.Name, *param.MinLength)
- }
- if param.MaxLength != nil && len(value) > *param.MaxLength {
- return fmt.Errorf("%s %q length must be <= %d", kind,
param.Name, *param.MaxLength)
- }
- case "integer":
- number, err := strconv.ParseInt(value, 10, 64)
- if err != nil {
- return fmt.Errorf("%s %q must be an integer", kind,
param.Name)
- }
- if param.Minimum != nil && float64(number) < *param.Minimum {
- return fmt.Errorf("%s %q must be >= %v", kind,
param.Name, *param.Minimum)
- }
- if param.Maximum != nil && float64(number) > *param.Maximum {
- return fmt.Errorf("%s %q must be <= %v", kind,
param.Name, *param.Maximum)
- }
- case "number":
- number, err := strconv.ParseFloat(value, 64)
- if err != nil {
- return fmt.Errorf("%s %q must be a number", kind,
param.Name)
- }
- if param.Minimum != nil && number < *param.Minimum {
- return fmt.Errorf("%s %q must be >= %v", kind,
param.Name, *param.Minimum)
- }
- if param.Maximum != nil && number > *param.Maximum {
- return fmt.Errorf("%s %q must be <= %v", kind,
param.Name, *param.Maximum)
- }
- case "boolean":
- if _, err := strconv.ParseBool(value); err != nil {
- return fmt.Errorf("%s %q must be a boolean", kind,
param.Name)
- }
- }
-
- return nil
-}
-
-func validateRequestBody(req *http.Request, body *BodyValidation) error {
- if body == nil {
- return nil
- }
-
- raw, err := io.ReadAll(req.Body)
- if err != nil {
- return fmt.Errorf("read request body: %w", err)
- }
- req.Body = io.NopCloser(bytes.NewReader(raw))
-
- if body.Required && len(bytes.TrimSpace(raw)) == 0 {
- return fmt.Errorf("request body is required")
- }
- if len(bytes.TrimSpace(raw)) == 0 || body.Schema == nil {
- return nil
- }
-
- if !strings.Contains(strings.ToLower(req.Header.Get("Content-Type")),
"application/json") {
- return fmt.Errorf("request body content-type must be
application/json")
- }
-
- var payload any
- if err := json.Unmarshal(raw, &payload); err != nil {
- return fmt.Errorf("request body must be valid json: %w", err)
- }
-
- return validateSchema("$", payload, body.Schema)
-}
-
-func validateSchema(path string, value any, schema *SchemaValidation) error {
- if schema == nil {
- return nil
- }
-
- switch schema.Type {
- case "object":
- obj, ok := value.(map[string]any)
- if !ok {
- return fmt.Errorf("%s must be an object", path)
- }
- for _, field := range schema.Required {
- if _, exists := obj[field]; !exists {
- return fmt.Errorf("%s.%s is required", path,
field)
- }
- }
- for key, child := range schema.Properties {
- childValue, exists := obj[key]
- if !exists {
- continue
- }
- if err := validateSchema(path+"."+key, childValue,
child); err != nil {
- return err
- }
- }
- case "string":
- str, ok := value.(string)
- if !ok {
- return fmt.Errorf("%s must be a string", path)
- }
- if schema.MinLength != nil && len(str) < *schema.MinLength {
- return fmt.Errorf("%s length must be >= %d", path,
*schema.MinLength)
- }
- if schema.MaxLength != nil && len(str) > *schema.MaxLength {
- return fmt.Errorf("%s length must be <= %d", path,
*schema.MaxLength)
- }
- if len(schema.Enum) > 0 && !contains(schema.Enum, str) {
- return fmt.Errorf("%s must be one of %v", path,
schema.Enum)
- }
- case "number":
- number, ok := toFloat64(value)
- if !ok {
- return fmt.Errorf("%s must be a number", path)
- }
- if schema.Minimum != nil && number < *schema.Minimum {
- return fmt.Errorf("%s must be >= %v", path,
*schema.Minimum)
- }
- if schema.Maximum != nil && number > *schema.Maximum {
- return fmt.Errorf("%s must be <= %v", path,
*schema.Maximum)
- }
- case "integer":
- number, ok := toFloat64(value)
- if !ok || math.Mod(number, 1) != 0 {
- return fmt.Errorf("%s must be an integer", path)
- }
- if schema.Minimum != nil && number < *schema.Minimum {
- return fmt.Errorf("%s must be >= %v", path,
*schema.Minimum)
- }
- if schema.Maximum != nil && number > *schema.Maximum {
- return fmt.Errorf("%s must be <= %v", path,
*schema.Maximum)
- }
- }
-
- return nil
-}
-
-func contains(values []string, candidate string) bool {
- for _, value := range values {
- if value == candidate {
- return true
- }
- }
- return false
-}
-
-func toFloat64(value any) (float64, bool) {
- switch typed := value.(type) {
- case float64:
- return typed, true
- case float32:
- return float64(typed), true
- case int:
- return float64(typed), true
- case int8:
- return float64(typed), true
- case int16:
- return float64(typed), true
- case int32:
- return float64(typed), true
- case int64:
- return float64(typed), true
- case uint:
- return float64(typed), true
- case uint8:
- return float64(typed), true
- case uint16:
- return float64(typed), true
- case uint32:
- return float64(typed), true
- case uint64:
- return float64(typed), true
- default:
- return 0, false
- }
-}
diff --git a/pkg/filter/http/apiconfig/openapi/validator_test.go
b/pkg/filter/http/apiconfig/openapi/validator_test.go
deleted file mode 100644
index b2d57ef55..000000000
--- a/pkg/filter/http/apiconfig/openapi/validator_test.go
+++ /dev/null
@@ -1,201 +0,0 @@
-/*
- * 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.
- */
-
-package openapi
-
-import (
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-)
-
-import (
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func TestValidateRequest_MissingRequiredQuery(t *testing.T) {
- req := httptest.NewRequest("GET", "/users", nil)
- plan := &ValidationPlan{
- RoutePattern: "/users",
- QueryParameters: []ParameterValidation{
- {Name: "id", Required: true},
- },
- }
-
- err := ValidateRequest(req, plan)
- require.Error(t, err)
- assert.ErrorContains(t, err, "id")
-}
-
-func TestValidateRequest_QueryEnumMismatch(t *testing.T) {
- req := httptest.NewRequest("GET", "/users?role=superadmin", nil)
- plan := &ValidationPlan{
- RoutePattern: "/users",
- QueryParameters: []ParameterValidation{
- {Name: "role", Enum: []string{"admin", "member"}},
- },
- }
-
- err := ValidateRequest(req, plan)
- require.Error(t, err)
- assert.ErrorContains(t, err, "role")
-}
-
-func TestValidateRequest_QueryTypeMismatch(t *testing.T) {
- req := httptest.NewRequest(http.MethodGet, "/users?page=abc", nil)
- plan := &ValidationPlan{
- RoutePattern: "/users",
- QueryParameters: []ParameterValidation{
- {Name: "page", Type: "integer", Required: true},
- },
- }
-
- err := ValidateRequest(req, plan)
- require.Error(t, err)
- assert.ErrorContains(t, err, "integer")
-}
-
-func TestValidateRequest_QueryRangeViolation(t *testing.T) {
- req := httptest.NewRequest(http.MethodGet, "/users?page=0", nil)
- plan := &ValidationPlan{
- RoutePattern: "/users",
- QueryParameters: []ParameterValidation{
- {Name: "page", Type: "integer", Minimum: float64Ptr(1),
Maximum: float64Ptr(100)},
- },
- }
-
- err := ValidateRequest(req, plan)
- require.Error(t, err)
- assert.ErrorContains(t, err, ">=")
-}
-
-func TestValidateRequest_InvalidJSONBody(t *testing.T) {
- req := httptest.NewRequest("POST", "/users",
strings.NewReader(`{"role":"member"}`))
- req.Header.Set("Content-Type", "application/json")
-
- plan := &ValidationPlan{
- RoutePattern: "/users",
- RequestBody: &BodyValidation{
- Required: true,
- Schema: &SchemaValidation{
- Type: "object",
- Required: []string{"name", "role"},
- Properties: map[string]*SchemaValidation{
- "name": {
- Type: "string",
- MaxLength: intPtr(32),
- },
- "role": {
- Type: "string",
- Enum: []string{"admin",
"member"},
- },
- },
- },
- },
- }
-
- err := ValidateRequest(req, plan)
- require.Error(t, err)
- assert.ErrorContains(t, err, "name")
-}
-
-func TestValidateRequest_MissingRequiredHeader(t *testing.T) {
- req := httptest.NewRequest(http.MethodGet, "/users/123", nil)
- plan := &ValidationPlan{
- RoutePattern: "/users/:id",
- PathParameters: []ParameterValidation{
- {Name: "id", Required: true},
- },
- HeaderParameters: []ParameterValidation{
- {Name: "trace-id", Required: true},
- },
- }
-
- err := ValidateRequest(req, plan)
- require.Error(t, err)
- assert.ErrorContains(t, err, "trace-id")
-}
-
-func TestValidateRequest_HeaderLengthViolation(t *testing.T) {
- req := httptest.NewRequest(http.MethodGet, "/users/123", nil)
- req.Header.Set("trace-id", "abc")
- plan := &ValidationPlan{
- RoutePattern: "/users/:id",
- HeaderParameters: []ParameterValidation{
- {Name: "trace-id", Type: "string", MinLength:
intPtr(4)},
- },
- }
-
- err := ValidateRequest(req, plan)
- require.Error(t, err)
- assert.ErrorContains(t, err, "length")
-}
-
-func TestValidateRequest_InvalidNumericBody(t *testing.T) {
- req := httptest.NewRequest(http.MethodPost, "/products",
strings.NewReader(`{"price":-10}`))
- req.Header.Set("Content-Type", "application/json")
-
- plan := &ValidationPlan{
- RoutePattern: "/products",
- RequestBody: &BodyValidation{
- Required: true,
- Schema: &SchemaValidation{
- Type: "object",
- Required: []string{"price"},
- Properties: map[string]*SchemaValidation{
- "price": {
- Type: "number",
- Minimum: float64Ptr(0),
- },
- },
- },
- },
- }
-
- err := ValidateRequest(req, plan)
- require.Error(t, err)
- assert.ErrorContains(t, err, "price")
-}
-
-func TestValidateRequest_RejectsUnsupportedBodyContentType(t *testing.T) {
- req := httptest.NewRequest(http.MethodPost, "/users",
strings.NewReader(`{"name":"tom"}`))
- req.Header.Set("Content-Type", "text/plain")
-
- plan := &ValidationPlan{
- RoutePattern: "/users",
- RequestBody: &BodyValidation{
- Required: true,
- Schema: &SchemaValidation{
- Type: "object",
- },
- },
- }
-
- err := ValidateRequest(req, plan)
- require.Error(t, err)
- assert.ErrorContains(t, err, "application/json")
-}
-
-func float64Ptr(v float64) *float64 {
- return &v
-}
-
-func intPtr(v int) *int {
- return &v
-}
diff --git a/pkg/filter/http/openapi/openapi.go
b/pkg/filter/http/openapi/openapi.go
new file mode 100644
index 000000000..170b17b3b
--- /dev/null
+++ b/pkg/filter/http/openapi/openapi.go
@@ -0,0 +1,567 @@
+/*
+ * 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.
+ */
+
+package openapi
+
+import (
+ "bytes"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+import (
+ "github.com/pb33f/libopenapi"
+ openapiValidator "github.com/pb33f/libopenapi-validator"
+ validatorConfig "github.com/pb33f/libopenapi-validator/config"
+ validatorErrors "github.com/pb33f/libopenapi-validator/errors"
+ validatorPaths "github.com/pb33f/libopenapi-validator/paths"
+ validatorRadix "github.com/pb33f/libopenapi-validator/radix"
+ "github.com/pb33f/libopenapi/datamodel"
+ v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
+
+ "github.com/pkg/errors"
+
+ "gopkg.in/yaml.v3"
+)
+
+import (
+ "github.com/apache/dubbo-go-pixiu/pkg/common/constant"
+ "github.com/apache/dubbo-go-pixiu/pkg/common/extension/filter"
+ contexthttp "github.com/apache/dubbo-go-pixiu/pkg/context/http"
+ "github.com/apache/dubbo-go-pixiu/pkg/logger"
+)
+
+const (
+ // Kind is the kind of OpenAPI validation filter.
+ Kind = constant.HTTPOpenAPIFilter
+
+ defaultMaxRequestBodyBytes = 1 << 20
+)
+
+var errOpenAPIRequestBodyTooLarge = errors.New("openapi request body too
large")
+
+func init() {
+ filter.RegisterHttpFilter(&Plugin{})
+}
+
+type (
+ Plugin struct {
+ }
+
+ FilterFactory struct {
+ cfg *Config
+ validator openapiValidator.Validator
+ model *v3.Document
+ validationOptions *validatorConfig.ValidationOptions
+ maxRequestBody int64
+ }
+
+ Filter struct {
+ validator openapiValidator.Validator
+ model *v3.Document
+ validationOptions *validatorConfig.ValidationOptions
+ maxRequestBody int64
+ }
+
+ Config struct {
+ Path string `yaml:"path" json:"path,omitempty"`
+ // MaxRequestBodyBytes limits how much request body data
OpenAPI validation may read.
+ // Zero uses the default limit.
+ MaxRequestBodyBytes int64 `yaml:"max_request_body_bytes"
json:"max_request_body_bytes,omitempty"`
+ }
+)
+
+func (p *Plugin) Kind() string {
+ return Kind
+}
+
+func (p *Plugin) CreateFilterFactory() (filter.HttpFilterFactory, error) {
+ return &FilterFactory{cfg: &Config{}}, nil
+}
+
+func (factory *FilterFactory) Config() any {
+ return factory.cfg
+}
+
+func (factory *FilterFactory) Apply() error {
+ path, err := cleanOpenAPIPath(factory.cfg.Path)
+ if err != nil {
+ return err
+ }
+ factory.cfg.Path = path
+
+ maxRequestBody, err := factory.cfg.effectiveMaxRequestBodyBytes()
+ if err != nil {
+ return err
+ }
+
+ validator, model, validationOptions, err := loadValidatorFromFile(path)
+ if err != nil {
+ return err
+ }
+ factory.validator = validator
+ factory.model = model
+ factory.validationOptions = validationOptions
+ factory.maxRequestBody = maxRequestBody
+ return nil
+}
+
+func (factory *FilterFactory) PrepareFilterChain(ctx *contexthttp.HttpContext,
chain filter.FilterChain) error {
+ f := &Filter{
+ validator: factory.validator,
+ model: factory.model,
+ validationOptions: factory.validationOptions,
+ maxRequestBody: factory.maxRequestBody,
+ }
+ chain.AppendDecodeFilters(f)
+ return nil
+}
+
+func (f *Filter) Decode(ctx *contexthttp.HttpContext) filter.FilterStatus {
+ if f.validator == nil || f.model == nil {
+ return filter.Continue
+ }
+
+ req := ctx.Request
+ pathItem, foundPath, ok := f.findRequestOperation(req)
+ if !ok {
+ return filter.Continue
+ }
+
+ if err := f.prepareRequestBodyForValidation(req); err != nil {
+ if err == errOpenAPIRequestBodyTooLarge {
+ errResp := contexthttp.PayloadTooLarge.WithError(err)
+ ctx.SendLocalReply(errResp.Status, errResp.ToJSON())
+ logger.Debug(errResp.Error())
+ return filter.Stop
+ }
+ errResp := contexthttp.BadRequest.WithError(errors.New("openapi
request body read failed"))
+ ctx.SendLocalReply(errResp.Status, errResp.ToJSON())
+ logger.Debugf("openapi request body read failed: %v", err)
+ return filter.Stop
+ }
+
+ if valid, validationErrs :=
f.validator.ValidateHttpRequestSyncWithPathItem(req, pathItem, foundPath);
!valid {
+ validationDetails := formatValidationErrors(validationErrs)
+ errResp := contexthttp.BadRequest.WithError(errors.New("openapi
request validation failed"))
+ ctx.SendLocalReply(errResp.Status, errResp.ToJSON())
+ logger.Debugf("openapi request validation failed: %s",
validationDetails)
+ return filter.Stop
+ }
+ return filter.Continue
+}
+
+func (f *Filter) findRequestOperation(req *http.Request) (*v3.PathItem,
string, bool) {
+ pathItem, validationErrs, foundPath := validatorPaths.FindPath(req,
f.model, f.validationOptions)
+ if len(validationErrs) > 0 {
+ logger.Debugf("openapi path lookup errors for %s %s: %s",
req.Method, req.URL.Path, formatValidationErrors(validationErrs))
+ return nil, "", false
+ }
+ if pathItem == nil {
+ return nil, "", false
+ }
+ if !hasRequestOperation(req, pathItem) {
+ return nil, "", false
+ }
+ return pathItem, foundPath, true
+}
+
+func hasRequestOperation(req *http.Request, pathItem *v3.PathItem) bool {
+ switch req.Method {
+ case http.MethodGet:
+ return pathItem.Get != nil
+ case http.MethodPost:
+ return pathItem.Post != nil
+ case http.MethodPut:
+ return pathItem.Put != nil
+ case http.MethodDelete:
+ return pathItem.Delete != nil
+ case http.MethodOptions:
+ return pathItem.Options != nil
+ case http.MethodHead:
+ return pathItem.Head != nil
+ case http.MethodPatch:
+ return pathItem.Patch != nil
+ case http.MethodTrace:
+ return pathItem.Trace != nil
+ default:
+ operations := pathItem.GetOperations()
+ return operations != nil &&
operations.GetOrZero(strings.ToLower(req.Method)) != nil
+ }
+}
+
+func (f *Filter) prepareRequestBodyForValidation(req *http.Request) error {
+ if req.Body == nil || req.Body == http.NoBody {
+ return nil
+ }
+ if f.maxRequestBody <= 0 {
+ return nil
+ }
+ if req.ContentLength > f.maxRequestBody {
+ return errOpenAPIRequestBodyTooLarge
+ }
+
+ body, err := readRequestBodyWithinLimit(req.Body, f.maxRequestBody)
+ if err != nil {
+ return err
+ }
+ req.Body = io.NopCloser(bytes.NewReader(body))
+ req.ContentLength = int64(len(body))
+ return nil
+}
+
+func readRequestBodyWithinLimit(body io.ReadCloser, limit int64) ([]byte,
error) {
+ // The original body is replaced by the caller after this bounded read.
+ defer body.Close()
+
+ data, err := io.ReadAll(io.LimitReader(body, limit+1))
+ if err != nil {
+ return nil, err
+ }
+ if int64(len(data)) > limit {
+ return nil, errOpenAPIRequestBodyTooLarge
+ }
+ return data, nil
+}
+
+func loadValidatorFromFile(path string) (openapiValidator.Validator,
*v3.Document, *validatorConfig.ValidationOptions, error) {
+ if err := validateExternalRefs(path); err != nil {
+ return nil, nil, nil, err
+ }
+
+ spec, err := os.ReadFile(path)
+ if err != nil {
+ return nil, nil, nil, errors.Wrap(err, "read openapi file")
+ }
+
+ doc, err := libopenapi.NewDocumentWithConfiguration(spec,
&datamodel.DocumentConfiguration{
+ BasePath: filepath.Dir(path),
+ })
+ if err != nil {
+ return nil, nil, nil, errors.Wrap(err, "parse openapi document")
+ }
+
+ model, err := doc.BuildV3Model()
+ if err != nil {
+ return nil, nil, nil, errors.Wrap(err, "build openapi model")
+ }
+
+ validationOptions :=
validatorConfig.NewValidationOptions(validatorConfig.WithoutSecurityValidation())
+ if validationOptions.PathTree == nil &&
!validationOptions.IsPathTreeDisabled() {
+ validationOptions.PathTree =
validatorRadix.BuildPathTree(&model.Model)
+ }
+
+ validator := openapiValidator.NewValidatorFromV3Model(&model.Model,
validatorConfig.WithExistingOpts(validationOptions))
+ if validator == nil {
+ return nil, nil, nil, errors.New("load openapi validator:
validator is nil")
+ }
+ return validator, &model.Model, validationOptions, nil
+}
+
+func cleanOpenAPIPath(path string) (string, error) {
+ path = strings.TrimSpace(path)
+ if path == "" {
+ return "", errors.New("openapi path is required")
+ }
+ if filepath.IsAbs(path) {
+ return "", errors.Errorf("openapi path must be relative: %s",
path)
+ }
+ if containsParentDirectory(path) {
+ return "", errors.Errorf("openapi path must not contain parent
directory: %s", path)
+ }
+
+ cleanPath := filepath.Clean(path)
+ if cleanPath == "." {
+ return "", errors.New("openapi path is required")
+ }
+
+ // Resolve symlinks so that a relative path like "specs/current/passwd"
+ // cannot bypass the sensitive-directory check when "specs/current" is
+ // a symlink pointing to /etc.
+ resolved, err := filepath.EvalSymlinks(cleanPath)
+ if err != nil {
+ return "", errors.Wrap(err, "resolve openapi path symlinks")
+ }
+
+ basePath, err := filepath.Abs(filepath.Dir(resolved))
+ if err != nil {
+ return "", errors.Wrap(err, "resolve openapi path base")
+ }
+ if isSensitiveOpenAPIBasePath(basePath) {
+ return "", errors.Errorf("openapi path base directory is not
allowed: %s", basePath)
+ }
+ return cleanPath, nil
+}
+
+func validateExternalRefs(rootPath string) error {
+ rootPath = filepath.Clean(rootPath)
+ rootDir := filepath.Dir(rootPath)
+ visited := map[string]struct{}{}
+ return validateExternalRefsInFile(rootPath, rootDir, visited)
+}
+
+func validateExternalRefsInFile(path string, rootDir string, visited
map[string]struct{}) error {
+ path = filepath.Clean(path)
+ if _, ok := visited[path]; ok {
+ return nil
+ }
+ visited[path] = struct{}{}
+
+ spec, err := os.ReadFile(path)
+ if err != nil {
+ return errors.Wrap(err, "read openapi file for ref validation")
+ }
+
+ var raw any
+ if err := yaml.Unmarshal(spec, &raw); err != nil {
+ return errors.Wrap(err, "parse openapi file for ref validation")
+ }
+
+ refs := collectExternalRefs(raw, nil)
+ for _, ref := range refs {
+ if err := validateExternalRefPath(path, rootDir, ref); err !=
nil {
+ return err
+ }
+
+ refPath, _, ok := splitRefTarget(ref)
+ if !ok {
+ continue
+ }
+ if filepath.Ext(refPath) == "" && strings.HasSuffix(refPath,
"/") {
+ continue
+ }
+ resolvedRefPath, err :=
resolveSafeReferencePath(filepath.Dir(path), refPath, rootDir)
+ if err != nil {
+ return err
+ }
+ if err := validateExternalRefsInFile(resolvedRefPath, rootDir,
visited); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func collectExternalRefs(value any, refs []string) []string {
+ switch v := value.(type) {
+ case map[string]any:
+ for key, child := range v {
+ if key == "$ref" {
+ if ref, ok := child.(string); ok && ref != ""
&& !strings.HasPrefix(ref, "#") {
+ refs = append(refs, ref)
+ }
+ continue
+ }
+ refs = collectExternalRefs(child, refs)
+ }
+ case []any:
+ for _, child := range v {
+ refs = collectExternalRefs(child, refs)
+ }
+ }
+ return refs
+}
+
+func splitRefTarget(ref string) (string, string, bool) {
+ if ref == "" {
+ return "", "", false
+ }
+ if strings.HasPrefix(ref, "#") {
+ return "", ref, false
+ }
+ parsed, err := url.Parse(ref)
+ if err != nil {
+ return "", "", false
+ }
+ if parsed.Scheme != "" && parsed.Scheme != "file" {
+ return "", "", false
+ }
+ target := parsed.Path
+ if target == "" {
+ target = parsed.Opaque
+ }
+ fragment := parsed.Fragment
+ if idx := strings.Index(target, "#"); idx >= 0 {
+ fragment = target[idx+1:]
+ target = target[:idx]
+ }
+ return target, fragment, true
+}
+
+func validateExternalRefPath(currentFile string, rootDir string, ref string)
error {
+ if ref == "" || strings.HasPrefix(ref, "#") {
+ return nil
+ }
+ parsed, err := url.Parse(ref)
+ if err != nil {
+ return errors.Wrap(err, "parse openapi external ref")
+ }
+ if parsed.Scheme != "" || parsed.Host != "" {
+ return errors.Errorf("openapi external ref must be relative:
%s", ref)
+ }
+
+ refPath, _, ok := splitRefTarget(ref)
+ if !ok {
+ return nil
+ }
+ if filepath.IsAbs(refPath) {
+ return errors.Errorf("openapi external ref must be relative:
%s", ref)
+ }
+ if containsParentDirectory(refPath) {
+ return errors.Errorf("openapi external ref must not contain
parent directory: %s", ref)
+ }
+ _, err = resolveSafeReferencePath(filepath.Dir(currentFile), refPath,
rootDir)
+ return err
+}
+
+func resolveSafeReferencePath(baseDir string, refPath string, rootDir string)
(string, error) {
+ candidate := filepath.Clean(filepath.Join(baseDir, refPath))
+ resolved, err := filepath.EvalSymlinks(candidate)
+ if err != nil {
+ return "", errors.Wrap(err, "resolve openapi external ref
symlinks")
+ }
+ if !isWithinBaseDir(resolved, rootDir) {
+ return "", errors.Errorf("openapi external ref escapes allowed
directory: %s", resolved)
+ }
+ return resolved, nil
+}
+
+func isWithinBaseDir(path string, baseDir string) bool {
+ path = filepath.Clean(path)
+ baseDir = filepath.Clean(baseDir)
+ if path == baseDir {
+ return true
+ }
+ rel, err := filepath.Rel(baseDir, path)
+ if err != nil {
+ return false
+ }
+ return rel != ".." && !strings.HasPrefix(rel,
".."+string(filepath.Separator))
+}
+
+func (c *Config) effectiveMaxRequestBodyBytes() (int64, error) {
+ if c.MaxRequestBodyBytes < 0 {
+ return 0, errors.New("max_request_body_bytes must not be
negative")
+ }
+ if c.MaxRequestBodyBytes == 0 {
+ return defaultMaxRequestBodyBytes, nil
+ }
+ return c.MaxRequestBodyBytes, nil
+}
+
+func containsParentDirectory(path string) bool {
+ for _, part := range strings.Split(filepath.ToSlash(path), "/") {
+ if part == ".." {
+ return true
+ }
+ }
+ return false
+}
+
+func isSensitiveOpenAPIBasePath(path string) bool {
+ // Resolve symlinks on the candidate path so that macOS /private/etc
+ // matches against the sensitive entry /etc.
+ resolved := resolveExistingAncestor(path)
+
+ if resolved == filepath.Clean(string(filepath.Separator)) {
+ return true
+ }
+
+ sensitivePaths := []string{"/etc", "/proc", "/sys", "/dev", "/run",
"/var/run"}
+ for _, sensitivePath := range sensitivePaths {
+ // Resolve symlinks on the sensitive path too (macOS: /etc ->
/private/etc).
+ resolvedSensitive := resolveExistingAncestor(sensitivePath)
+ if isPathWithin(resolved, resolvedSensitive) {
+ return true
+ }
+ }
+ return false
+}
+
+// resolveExistingAncestor resolves symlinks on the longest existing ancestor
+// of path, then appends any remaining non-existent components. This avoids
+// EvalSymlinks failing on paths whose leaf does not yet exist on disk.
+func resolveExistingAncestor(path string) string {
+ path = filepath.Clean(path)
+
+ // Walk up until we find an existing component.
+ candidate := path
+ var tail []string
+ for {
+ _, err := os.Lstat(candidate)
+ if err == nil {
+ break
+ }
+ parent := filepath.Dir(candidate)
+ if parent == candidate {
+ // Reached root without finding an existing component.
+ return path
+ }
+ tail = append([]string{filepath.Base(candidate)}, tail...)
+ candidate = parent
+ }
+
+ resolved, err := filepath.EvalSymlinks(candidate)
+ if err != nil {
+ return path
+ }
+ if len(tail) == 0 {
+ return resolved
+ }
+ return filepath.Join(append([]string{resolved}, tail...)...)
+}
+
+func isPathWithin(path string, base string) bool {
+ base = filepath.Clean(base)
+ if path == base {
+ return true
+ }
+
+ rel, err := filepath.Rel(base, path)
+ if err != nil {
+ return false
+ }
+ return rel != ".." && !strings.HasPrefix(rel,
".."+string(filepath.Separator))
+}
+
+func formatValidationErrors(errs []*validatorErrors.ValidationError) string {
+ messages := make([]string, 0, len(errs))
+ for _, validationErr := range errs {
+ if validationErr == nil {
+ continue
+ }
+ switch {
+ case validationErr.Message != "" && validationErr.Reason != "":
+ messages = append(messages, validationErr.Message+":
"+validationErr.Reason)
+ case validationErr.Message != "":
+ messages = append(messages, validationErr.Message)
+ case validationErr.Reason != "":
+ messages = append(messages, validationErr.Reason)
+ default:
+ messages = append(messages, validationErr.Error())
+ }
+ }
+ if len(messages) == 0 {
+ return "openapi request validation failed"
+ }
+ return strings.Join(messages, "; ")
+}
+
+var _ filter.HttpFilterFactory = new(FilterFactory)
diff --git a/pkg/filter/http/openapi/openapi_test.go
b/pkg/filter/http/openapi/openapi_test.go
new file mode 100644
index 000000000..56f167718
--- /dev/null
+++ b/pkg/filter/http/openapi/openapi_test.go
@@ -0,0 +1,834 @@
+/*
+ * 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.
+ */
+
+package openapi
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+import (
+ v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+import (
+ extfilter "github.com/apache/dubbo-go-pixiu/pkg/common/extension/filter"
+ contexthttp "github.com/apache/dubbo-go-pixiu/pkg/context/http"
+)
+
+func TestDecode_StopsOnOpenAPIValidationFailure(t *testing.T) {
+ filterInstance := newOpenAPIFilter(t, usersSpecWithQueryAndBody())
+ req := httptest.NewRequest(http.MethodPost, "/users",
strings.NewReader(`{"name":"tom"}`))
+ req.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ ctx := &contexthttp.HttpContext{Request: req, Writer: recorder}
+
+ status := filterInstance.Decode(ctx)
+
+ assert.Equal(t, extfilter.Stop, status)
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ assert.True(t, ctx.LocalReply())
+}
+
+func TestDecode_HidesOpenAPIValidationDetailsFromClient(t *testing.T) {
+ filterInstance := newOpenAPIFilter(t, usersSpecWithQueryAndBody())
+ req := httptest.NewRequest(http.MethodPost, "/users",
strings.NewReader(`{"name":"tom"}`))
+ req.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ ctx := &contexthttp.HttpContext{Request: req, Writer: recorder}
+
+ status := filterInstance.Decode(ctx)
+
+ assert.Equal(t, extfilter.Stop, status)
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), "openapi request validation
failed")
+ assert.NotContains(t, recorder.Body.String(), "source")
+}
+
+func TestDecode_RejectsOpenAPIRequestBodyLargerThanLimit(t *testing.T) {
+ filterInstance := newOpenAPIFilterWithConfig(t,
usersSpecWithQueryAndBody(), func(cfg *Config) {
+ cfg.MaxRequestBodyBytes = 8
+ })
+ req := httptest.NewRequest(http.MethodPost, "/users?source=web",
strings.NewReader(`{"name":"tom"}`))
+ req.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ ctx := &contexthttp.HttpContext{Request: req, Writer: recorder}
+
+ status := filterInstance.Decode(ctx)
+
+ assert.Equal(t, extfilter.Stop, status)
+ assert.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), "openapi request body too
large")
+}
+
+func TestDecode_RejectsChunkedOpenAPIRequestBodyLargerThanLimit(t *testing.T) {
+ filterInstance := newOpenAPIFilterWithConfig(t,
usersSpecWithQueryAndBody(), func(cfg *Config) {
+ cfg.MaxRequestBodyBytes = 8
+ })
+ req := httptest.NewRequest(http.MethodPost, "/users?source=web",
io.NopCloser(strings.NewReader(`{"name":"tom"}`)))
+ req.ContentLength = -1
+ req.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ ctx := &contexthttp.HttpContext{Request: req, Writer: recorder}
+
+ status := filterInstance.Decode(ctx)
+
+ assert.Equal(t, extfilter.Stop, status)
+ assert.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), "openapi request body too
large")
+}
+
+func TestDecode_ContinuesOnOpenAPIValidationSuccess(t *testing.T) {
+ filterInstance := newOpenAPIFilter(t, usersSpecWithQueryAndBody())
+ req := httptest.NewRequest(http.MethodPost, "/users?source=web",
strings.NewReader(`{"name":"tom"}`))
+ req.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ ctx := &contexthttp.HttpContext{Request: req, Writer: recorder}
+
+ status := filterInstance.Decode(ctx)
+
+ assert.Equal(t, extfilter.Continue, status)
+ body, err := io.ReadAll(req.Body)
+ require.NoError(t, err)
+ assert.JSONEq(t, `{"name":"tom"}`, string(body))
+}
+
+func TestDecode_IgnoresOpenAPISecurityValidation(t *testing.T) {
+ filterInstance := newOpenAPIFilter(t, `
+openapi: 3.0.3
+info:
+ title: users
+ version: "1.0.0"
+components:
+ securitySchemes:
+ ApiKeyAuth:
+ type: apiKey
+ in: header
+ name: X-API-Key
+security:
+ - ApiKeyAuth: []
+paths:
+ /users:
+ post:
+ parameters:
+ - name: source
+ in: query
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: ok
+`)
+ req := httptest.NewRequest(http.MethodPost, "/users?source=web", nil)
+ recorder := httptest.NewRecorder()
+ ctx := &contexthttp.HttpContext{Request: req, Writer: recorder}
+
+ status := filterInstance.Decode(ctx)
+
+ assert.Equal(t, extfilter.Continue, status)
+ assert.False(t, ctx.LocalReply())
+}
+
+func TestDecode_SkipsRoutesNotDeclaredInOpenAPI(t *testing.T) {
+ filterInstance := newOpenAPIFilter(t, usersSpecWithQueryAndBody())
+ req := httptest.NewRequest(http.MethodGet, "/orders", nil)
+ recorder := httptest.NewRecorder()
+ ctx := &contexthttp.HttpContext{Request: req, Writer: recorder}
+
+ status := filterInstance.Decode(ctx)
+
+ assert.Equal(t, extfilter.Continue, status)
+ assert.False(t, ctx.LocalReply())
+}
+
+func TestDecode_SkipsMethodsNotDeclaredInOpenAPI(t *testing.T) {
+ filterInstance := newOpenAPIFilter(t, `
+openapi: 3.0.3
+info:
+ title: users
+ version: "1.0.0"
+paths:
+ /users:
+ get:
+ responses:
+ "200":
+ description: ok
+`)
+ req := httptest.NewRequest(http.MethodPost, "/users",
strings.NewReader(`{"name":"tom"}`))
+ req.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ ctx := &contexthttp.HttpContext{Request: req, Writer: recorder}
+
+ status := filterInstance.Decode(ctx)
+
+ assert.Equal(t, extfilter.Continue, status)
+ assert.False(t, ctx.LocalReply())
+}
+
+func TestDecode_SkipsHeadWhenOnlyGetDeclared(t *testing.T) {
+ // When only GET is declared (no HEAD), the SDK FindPath treats HEAD as
+ // an undeclared method and returns nil. The filter skips validation and
+ // lets the request continue to downstream filters / handlers.
+ filterInstance := newOpenAPIFilter(t, `
+openapi: 3.0.3
+info:
+ title: users
+ version: "1.0.0"
+paths:
+ /users:
+ get:
+ parameters:
+ - name: source
+ in: query
+ required: true
+ schema:
+ type: string
+ enum: [web, app]
+ responses:
+ "200":
+ description: ok
+`)
+
+ t.Run("HEAD without required query param is skipped", func(t
*testing.T) {
+ req := httptest.NewRequest(http.MethodHead, "/users", nil)
+ recorder := httptest.NewRecorder()
+ ctx := &contexthttp.HttpContext{Request: req, Writer: recorder}
+
+ status := filterInstance.Decode(ctx)
+
+ assert.Equal(t, extfilter.Continue, status)
+ assert.False(t, ctx.LocalReply())
+ })
+
+ t.Run("HEAD with valid query param is also skipped", func(t *testing.T)
{
+ req := httptest.NewRequest(http.MethodHead,
"/users?source=web", nil)
+ recorder := httptest.NewRecorder()
+ ctx := &contexthttp.HttpContext{Request: req, Writer: recorder}
+
+ status := filterInstance.Decode(ctx)
+
+ assert.Equal(t, extfilter.Continue, status)
+ assert.False(t, ctx.LocalReply())
+ })
+}
+
+func TestHasRequestOperation_HeadRequiresExplicitHeadOperation(t *testing.T) {
+ req := httptest.NewRequest(http.MethodHead, "/users", nil)
+
+ assert.False(t, hasRequestOperation(req, &v3.PathItem{
+ Get: &v3.Operation{},
+ }))
+ assert.True(t, hasRequestOperation(req, &v3.PathItem{
+ Head: &v3.Operation{},
+ }))
+}
+
+func TestApply_RejectsInvalidOpenAPIModel(t *testing.T) {
+ dir := t.TempDir()
+ t.Chdir(dir)
+ require.NoError(t, os.WriteFile("openapi.yaml", []byte(`
+openapi: 3.0.3
+info:
+ title: users
+ version: "1.0.0"
+paths:
+ /users:
+ post:
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/User"
+ responses:
+ "200":
+ description: ok
+components:
+ schemas:
+ User:
+ $ref: "#/components/schemas/User2"
+`), 0o600))
+
+ factory := &FilterFactory{
+ cfg: &Config{
+ Path: "openapi.yaml",
+ },
+ }
+
+ err := factory.Apply()
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "build openapi model")
+}
+
+func TestApply_RejectsAbsoluteOpenAPIPath(t *testing.T) {
+ factory := &FilterFactory{
+ cfg: &Config{
+ Path: "/etc/passwd",
+ },
+ }
+
+ err := factory.Apply()
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "openapi path must be relative")
+}
+
+func TestApply_RejectsParentDirectoryOpenAPIPath(t *testing.T) {
+ factory := &FilterFactory{
+ cfg: &Config{
+ Path: "../openapi.yaml",
+ },
+ }
+
+ err := factory.Apply()
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "openapi path must not contain parent
directory")
+}
+
+func TestApply_RejectsSymlinkPointingToSensitiveDirectory(t *testing.T) {
+ dir := t.TempDir()
+ t.Chdir(dir)
+
+ // Create a symlink: specs/current -> /etc
+ require.NoError(t, os.MkdirAll("specs", 0o700))
+ require.NoError(t, os.Symlink("/etc", filepath.Join(dir, "specs",
"current")))
+
+ factory := &FilterFactory{
+ cfg: &Config{
+ Path: "specs/current/passwd",
+ },
+ }
+
+ err := factory.Apply()
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "openapi path base directory is not
allowed")
+}
+
+func TestApply_AllowsNestedLocalExternalRefs(t *testing.T) {
+ dir := t.TempDir()
+ t.Chdir(dir)
+ require.NoError(t, os.MkdirAll("specs/schemas", 0o700))
+ require.NoError(t, os.WriteFile("specs/openapi.yaml", []byte(`
+openapi: 3.0.3
+info:
+ title: users
+ version: "1.0.0"
+paths:
+ /users:
+ post:
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: ./schemas/user.yaml#/User
+ responses:
+ "200":
+ description: ok
+`), 0o600))
+ require.NoError(t, os.WriteFile("specs/schemas/user.yaml", []byte(`
+User:
+ type: object
+ required: [name]
+ properties:
+ name:
+ $ref: ./name.yaml#/Name
+`), 0o600))
+ require.NoError(t, os.WriteFile("specs/schemas/name.yaml", []byte(`
+Name:
+ type: string
+ minLength: 3
+`), 0o600))
+
+ factory := &FilterFactory{
+ cfg: &Config{
+ Path: "specs/openapi.yaml",
+ },
+ }
+
+ require.NoError(t, factory.Apply())
+}
+
+func TestApply_RejectsExternalRefWithParentDirectory(t *testing.T) {
+ dir := t.TempDir()
+ t.Chdir(dir)
+ require.NoError(t, os.MkdirAll("specs", 0o700))
+ require.NoError(t, os.WriteFile("specs/openapi.yaml", []byte(`
+openapi: 3.0.3
+info:
+ title: users
+ version: "1.0.0"
+paths:
+ /users:
+ post:
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: ../schemas/user.yaml#/User
+ responses:
+ "200":
+ description: ok
+`), 0o600))
+
+ factory := &FilterFactory{
+ cfg: &Config{
+ Path: "specs/openapi.yaml",
+ },
+ }
+
+ err := factory.Apply()
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "openapi external ref must not contain
parent directory")
+}
+
+func TestApply_RejectsAbsoluteExternalRef(t *testing.T) {
+ dir := t.TempDir()
+ t.Chdir(dir)
+ require.NoError(t, os.WriteFile("openapi.yaml", []byte(`
+openapi: 3.0.3
+info:
+ title: users
+ version: "1.0.0"
+paths:
+ /users:
+ post:
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: /etc/passwd#/User
+ responses:
+ "200":
+ description: ok
+`), 0o600))
+
+ factory := &FilterFactory{
+ cfg: &Config{
+ Path: "openapi.yaml",
+ },
+ }
+
+ err := factory.Apply()
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "openapi external ref must be relative")
+}
+
+func TestApply_RejectsRemoteExternalRef(t *testing.T) {
+ dir := t.TempDir()
+ t.Chdir(dir)
+ require.NoError(t, os.WriteFile("openapi.yaml", []byte(`
+openapi: 3.0.3
+info:
+ title: users
+ version: "1.0.0"
+paths:
+ /users:
+ post:
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: https://example.com/schemas/user.yaml#/User
+ responses:
+ "200":
+ description: ok
+`), 0o600))
+
+ factory := &FilterFactory{
+ cfg: &Config{
+ Path: "openapi.yaml",
+ },
+ }
+
+ err := factory.Apply()
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "openapi external ref must be relative")
+}
+
+func TestApply_RejectsNestedExternalRefEscapingAllowedDirectoryViaSymlink(t
*testing.T) {
+ dir := t.TempDir()
+ t.Chdir(dir)
+ require.NoError(t, os.MkdirAll("specs/schemas", 0o700))
+ require.NoError(t, os.Symlink("/etc", filepath.Join(dir, "specs",
"schemas", "current")))
+ require.NoError(t, os.WriteFile("specs/openapi.yaml", []byte(`
+openapi: 3.0.3
+info:
+ title: users
+ version: "1.0.0"
+paths:
+ /users:
+ post:
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: ./schemas/user.yaml#/User
+ responses:
+ "200":
+ description: ok
+`), 0o600))
+ require.NoError(t, os.WriteFile("specs/schemas/user.yaml", []byte(`
+User:
+ type: object
+ properties:
+ name:
+ $ref: ./current/passwd#/Name
+`), 0o600))
+
+ factory := &FilterFactory{
+ cfg: &Config{
+ Path: "specs/openapi.yaml",
+ },
+ }
+
+ err := factory.Apply()
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "openapi external ref escapes allowed
directory")
+}
+
+func TestApply_RejectsNegativeMaxRequestBodyBytes(t *testing.T) {
+ dir := t.TempDir()
+ t.Chdir(dir)
+ require.NoError(t, os.WriteFile("openapi.yaml",
[]byte(usersSpecWithQueryAndBody()), 0o600))
+
+ factory := &FilterFactory{
+ cfg: &Config{
+ Path: "openapi.yaml",
+ MaxRequestBodyBytes: -1,
+ },
+ }
+
+ err := factory.Apply()
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "max_request_body_bytes must not be
negative")
+}
+
+func TestApply_UsesDefaultMaxRequestBodyBytesForZeroValue(t *testing.T) {
+ factory := newOpenAPIFactory(t, usersSpecWithQueryAndBody(), func(cfg
*Config) {
+ cfg.MaxRequestBodyBytes = 0
+ })
+
+ assert.Equal(t, int64(defaultMaxRequestBodyBytes),
factory.maxRequestBody)
+}
+
+func TestApply_BuildsReusablePathLookupOptions(t *testing.T) {
+ factory := newOpenAPIFactory(t, usersSpecWithQueryAndBody(), nil)
+
+ require.NotNil(t, factory.validationOptions)
+ require.NotNil(t, factory.validationOptions.PathTree)
+
+ filterInstance := &Filter{
+ validator: factory.validator,
+ model: factory.model,
+ validationOptions: factory.validationOptions,
+ }
+ req := httptest.NewRequest(http.MethodPost, "/users?source=web",
strings.NewReader(`{"name":"tom"}`))
+
+ pathItem, foundPath, ok := filterInstance.findRequestOperation(req)
+
+ require.True(t, ok)
+ require.NotNil(t, pathItem)
+ assert.Equal(t, "/users", foundPath)
+}
+
+func TestDecode_ValidatesTemplatedPaths(t *testing.T) {
+ filterInstance := newOpenAPIFilter(t, `
+openapi: 3.0.3
+info:
+ title: users
+ version: "1.0.0"
+paths:
+ /users/{id}:
+ get:
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ responses:
+ "200":
+ description: ok
+`)
+ req := httptest.NewRequest(http.MethodGet, "/users/abc", nil)
+ recorder := httptest.NewRecorder()
+ ctx := &contexthttp.HttpContext{Request: req, Writer: recorder}
+
+ status := filterInstance.Decode(ctx)
+
+ assert.Equal(t, extfilter.Stop, status)
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ assert.True(t, ctx.LocalReply())
+}
+
+func TestDecode_ValidatesHeaderRequiredAndType(t *testing.T) {
+ filterInstance := newOpenAPIFilter(t, `
+openapi: 3.0.3
+info:
+ title: reports
+ version: "1.0.0"
+paths:
+ /reports:
+ get:
+ parameters:
+ - name: X-Tenant-ID
+ in: header
+ required: true
+ schema:
+ type: integer
+ responses:
+ "200":
+ description: ok
+`)
+
+ tests := []struct {
+ name string
+ headerValue string
+ wantStatus extfilter.FilterStatus
+ }{
+ {
+ name: "missing required header",
+ wantStatus: extfilter.Stop,
+ },
+ {
+ name: "invalid header type",
+ headerValue: "tenant-a",
+ wantStatus: extfilter.Stop,
+ },
+ {
+ name: "valid header",
+ headerValue: "42",
+ wantStatus: extfilter.Continue,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/reports",
nil)
+ if tt.headerValue != "" {
+ req.Header.Set("X-Tenant-ID", tt.headerValue)
+ }
+ recorder := httptest.NewRecorder()
+ ctx := &contexthttp.HttpContext{Request: req, Writer:
recorder}
+
+ status := filterInstance.Decode(ctx)
+
+ assert.Equal(t, tt.wantStatus, status)
+ assert.Equal(t, tt.wantStatus == extfilter.Stop,
ctx.LocalReply())
+ })
+ }
+}
+
+func TestDecode_ValidatesQueryEnum(t *testing.T) {
+ filterInstance := newOpenAPIFilter(t, usersSpecWithQueryAndBody())
+ req := httptest.NewRequest(http.MethodPost, "/users?source=cli",
strings.NewReader(`{"name":"tom"}`))
+ req.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ ctx := &contexthttp.HttpContext{Request: req, Writer: recorder}
+
+ status := filterInstance.Decode(ctx)
+
+ assert.Equal(t, extfilter.Stop, status)
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ assert.True(t, ctx.LocalReply())
+}
+
+func TestDecode_ValidatesRequestBodyMinAndMaxLength(t *testing.T) {
+ filterInstance := newOpenAPIFilter(t, usersSpecWithQueryAndBody())
+
+ tests := []struct {
+ name string
+ body string
+ wantStatus extfilter.FilterStatus
+ }{
+ {
+ name: "below minLength",
+ body: `{"name":"Al"}`,
+ wantStatus: extfilter.Stop,
+ },
+ {
+ name: "above maxLength",
+ body: `{"name":"Alexandria"}`,
+ wantStatus: extfilter.Stop,
+ },
+ {
+ name: "within length bounds",
+ body: `{"name":"Alice"}`,
+ wantStatus: extfilter.Continue,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost,
"/users?source=web", strings.NewReader(tt.body))
+ req.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ ctx := &contexthttp.HttpContext{Request: req, Writer:
recorder}
+
+ status := filterInstance.Decode(ctx)
+
+ assert.Equal(t, tt.wantStatus, status)
+ assert.Equal(t, tt.wantStatus == extfilter.Stop,
ctx.LocalReply())
+ })
+ }
+}
+
+func TestDecode_ValidatesRequestBodyMinimumAndMaximum(t *testing.T) {
+ filterInstance := newOpenAPIFilter(t, usersSpecWithQueryAndBody())
+
+ tests := []struct {
+ name string
+ body string
+ wantStatus extfilter.FilterStatus
+ }{
+ {
+ name: "below minimum",
+ body: `{"name":"Alice","age":12}`,
+ wantStatus: extfilter.Stop,
+ },
+ {
+ name: "above maximum",
+ body: `{"name":"Alice","age":121}`,
+ wantStatus: extfilter.Stop,
+ },
+ {
+ name: "within numeric bounds",
+ body: `{"name":"Alice","age":18}`,
+ wantStatus: extfilter.Continue,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost,
"/users?source=web", strings.NewReader(tt.body))
+ req.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ ctx := &contexthttp.HttpContext{Request: req, Writer:
recorder}
+
+ status := filterInstance.Decode(ctx)
+
+ assert.Equal(t, tt.wantStatus, status)
+ assert.Equal(t, tt.wantStatus == extfilter.Stop,
ctx.LocalReply())
+ })
+ }
+}
+
+func newOpenAPIFilter(t *testing.T, spec string) *Filter {
+ t.Helper()
+ factory := newOpenAPIFactory(t, spec, nil)
+ return &Filter{
+ validator: factory.validator,
+ model: factory.model,
+ validationOptions: factory.validationOptions,
+ maxRequestBody: factory.maxRequestBody,
+ }
+}
+
+func newOpenAPIFilterWithConfig(t *testing.T, spec string, configure
func(*Config)) *Filter {
+ t.Helper()
+ factory := newOpenAPIFactory(t, spec, configure)
+ return &Filter{
+ validator: factory.validator,
+ model: factory.model,
+ validationOptions: factory.validationOptions,
+ maxRequestBody: factory.maxRequestBody,
+ }
+}
+
+func newOpenAPIFactory(t *testing.T, spec string, configure func(*Config))
*FilterFactory {
+ t.Helper()
+
+ dir := t.TempDir()
+ t.Chdir(dir)
+ specPath := "openapi.yaml"
+ require.NoError(t, os.WriteFile(filepath.Join(dir, specPath),
[]byte(spec), 0o600))
+
+ cfg := &Config{
+ Path: specPath,
+ }
+ if configure != nil {
+ configure(cfg)
+ }
+
+ factory := &FilterFactory{
+ cfg: cfg,
+ }
+ require.NoError(t, factory.Apply())
+
+ return factory
+}
+
+func usersSpecWithQueryAndBody() string {
+ return `
+openapi: 3.0.3
+info:
+ title: users
+ version: "1.0.0"
+paths:
+ /users:
+ post:
+ parameters:
+ - name: source
+ in: query
+ required: true
+ schema:
+ type: string
+ enum: [web, app]
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [name]
+ properties:
+ name:
+ type: string
+ minLength: 3
+ maxLength: 8
+ age:
+ type: integer
+ minimum: 13
+ maximum: 120
+ responses:
+ "200":
+ description: ok
+`
+}
diff --git a/pkg/pluginregistry/registry.go b/pkg/pluginregistry/registry.go
index ead498471..cc7c10acf 100644
--- a/pkg/pluginregistry/registry.go
+++ b/pkg/pluginregistry/registry.go
@@ -46,6 +46,7 @@ import (
_ "github.com/apache/dubbo-go-pixiu/pkg/filter/http/grpcproxy"
_ "github.com/apache/dubbo-go-pixiu/pkg/filter/http/httpproxy"
_ "github.com/apache/dubbo-go-pixiu/pkg/filter/http/loadbalancer"
+ _ "github.com/apache/dubbo-go-pixiu/pkg/filter/http/openapi"
_ "github.com/apache/dubbo-go-pixiu/pkg/filter/http/proxyrewrite"
_ "github.com/apache/dubbo-go-pixiu/pkg/filter/http/remote"
_ "github.com/apache/dubbo-go-pixiu/pkg/filter/llm/proxy"