You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@dubbo.apache.org by xu...@apache.org on 2026/05/15 05:07:43 UTC
(dubbo-go-pixiu) branch develop updated: Feature/857 Add OpenAPI request validation to apiconfig (#909)
This is an automated email from the ASF dual-hosted git repository.
Alanxtl 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 773de107 Feature/857 Add OpenAPI request validation to apiconfig (#909)
773de107 is described below
commit 773de1077ac57104f71ea920dca11c19d85f91aa
Author: linmao <lm...@gmail.com>
AuthorDate: Fri May 15 13:07:37 2026 +0800
Feature/857 Add OpenAPI request validation to apiconfig (#909)
* feat(apiconfig): add OpenAPI request validation
* docs(openapi): add usage guide and sample spec
* fix(apiconfig): align OpenAPI validation with V1 docs
* Prevent duplicate OpenAPI route registration
Merge OpenAPI validation metadata into existing api_config routes so duplicate path+method entries do not fail startup.
Tested: go test ./pkg/filter/http/apiconfig/...; go test ./...
---------
Co-authored-by: Tianlm <sk...@TianlmdeMacBook-Air.local>
---
configs/openapi_users.yaml | 56 ++++
docs/user/README.md | 1 +
docs/user/filter/openapi.md | 90 +++++++
docs/user/filter/openapi_CN.md | 90 +++++++
go.mod | 5 +
go.sum | 10 +
pkg/filter/http/apiconfig/api/discovery_service.go | 6 +
pkg/filter/http/apiconfig/api_config.go | 66 ++++-
pkg/filter/http/apiconfig/api_config_test.go | 240 +++++++++++++++++
pkg/filter/http/apiconfig/config.go | 10 +-
pkg/filter/http/apiconfig/openapi/compiler.go | 284 ++++++++++++++++++++
pkg/filter/http/apiconfig/openapi/compiler_test.go | 203 ++++++++++++++
.../http/apiconfig/{config.go => openapi/doc.go} | 16 +-
.../apiconfig/{config.go => openapi/metadata.go} | 23 +-
.../http/apiconfig/{config.go => openapi/model.go} | 45 +++-
pkg/filter/http/apiconfig/openapi/validator.go | 292 +++++++++++++++++++++
.../http/apiconfig/openapi/validator_test.go | 201 ++++++++++++++
pkg/router/api.go | 1 +
pkg/router/route.go | 43 +++
19 files changed, 1645 insertions(+), 37 deletions(-)
diff --git a/configs/openapi_users.yaml b/configs/openapi_users.yaml
new file mode 100644
index 00000000..c3b57567
--- /dev/null
+++ b/configs/openapi_users.yaml
@@ -0,0 +1,56 @@
+openapi: 3.0.3
+info:
+ title: pixiu users api
+ 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
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - name
+ - role
+ properties:
+ name:
+ type: string
+ maxLength: 32
+ role:
+ type: string
+ enum:
+ - admin
+ - member
+ age:
+ type: integer
+ minimum: 0
+ responses:
+ "200":
+ description: ok
diff --git a/docs/user/README.md b/docs/user/README.md
index e5e7d676..902fa128 100644
--- a/docs/user/README.md
+++ b/docs/user/README.md
@@ -4,4 +4,5 @@
* [Api Model](api.md)
* [Response](response.md)
* [Installation](installation.md)
+* [OpenAPI Request Validation Filter](filter/openapi.md)
* [SAML Auth Filter](filter/saml.md)
diff --git a/docs/user/filter/openapi.md b/docs/user/filter/openapi.md
new file mode 100644
index 00000000..10c80a34
--- /dev/null
+++ b/docs/user/filter/openapi.md
@@ -0,0 +1,90 @@
+# OpenAPI Request Validation Filter
+
+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`.
+
+When a request matches an API, Pixiu validates the request before it is forwarded upstream.
+
+If validation fails, Pixiu returns a `400 Bad Request` locally and stops the filter chain.
+
+## Supported In V1
+
+- local OpenAPI 3.x file loading
+- path parameter extraction
+- query parameter validation
+- header validation
+- JSON request body validation
+- `required`
+- `type`
+- `enum`
+- `minimum`
+- `maximum`
+- `minLength`
+- `maxLength`
+
+## Not Included In V1
+
+- response validation
+- remote `$ref`
+- `oneOf` / `allOf` / `anyOf`
+- admin or config-center distribution of OpenAPI files
+- `dynamic + openapi_path` combined configuration
+
+## Example Filter Config
+
+```yaml
+- name: dgp.filter.http.apiconfig
+ config:
+ path: configs/api_config.yaml
+ openapi_path: configs/openapi_users.yaml
+ enable_openapi_validation: true
+```
+
+OpenAPI validation does not create standalone routes. The route must already exist in `api_config`.
+
+## Notes
+
+- 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.
+
+## 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`.
+
+## Example Requests
+
+Assume the config points at `configs/openapi_users.yaml`.
+
+Valid request:
+
+```http
+POST /users?source=web
+Content-Type: application/json
+
+{"name":"tom","role":"admin","age":18}
+```
+
+Invalid request:
+
+```http
+POST /users
+Content-Type: application/json
+
+{"name":"tom","role":"admin"}
+```
+
+The invalid request is rejected because `source` is required by the OpenAPI query parameter definition.
diff --git a/docs/user/filter/openapi_CN.md b/docs/user/filter/openapi_CN.md
new file mode 100644
index 00000000..e8a0b2ad
--- /dev/null
+++ b/docs/user/filter/openapi_CN.md
@@ -0,0 +1,90 @@
+# OpenAPI 请求校验过滤器
+
+[English](openapi.md) | 中文
+
+---
+
+## 概述
+
+Pixiu 可以在 `dgp.filter.http.apiconfig` 初始化阶段加载 OpenAPI 3.x 文件,并把每个 operation 的
+`ValidationPlan` 合并到 `api_config` 里已经存在的同名路由上。
+
+当请求命中 API 后,Pixiu 会在转发到上游之前先执行请求校验。
+
+如果校验失败,Pixiu 会直接返回 `400 Bad Request`,并停止后续过滤链。
+
+## 第一版支持范围
+
+- 本地 OpenAPI 3.x 文件加载
+- path 参数提取
+- query 参数校验
+- header 校验
+- JSON request body 校验
+- `required`
+- `type`
+- `enum`
+- `minimum`
+- `maximum`
+- `minLength`
+- `maxLength`
+
+## 第一版暂不支持
+
+- response validation
+- 远程 `$ref`
+- `oneOf` / `allOf` / `anyOf`
+- admin 或配置中心分发 OpenAPI 文件
+- `dynamic + openapi_path` 组合配置
+
+## 配置示例
+
+```yaml
+- name: dgp.filter.http.apiconfig
+ config:
+ path: configs/api_config.yaml
+ openapi_path: configs/openapi_users.yaml
+ enable_openapi_validation: true
+```
+
+OpenAPI 校验不会单独创建路由,目标路由必须已经存在于 `api_config` 中。
+
+## 说明
+
+- 参数级校验现在覆盖 `path`、`query`、`header` 上常见的标量类型约束。
+- 第一版不支持在同一个 filter 配置里同时使用 `dynamic` 和 `openapi_path`。
+
+## 运行流程
+
+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`。
+
+## 请求示例
+
+假设配置引用的是 `configs/openapi_users.yaml`。
+
+合法请求:
+
+```http
+POST /users?source=web
+Content-Type: application/json
+
+{"name":"tom","role":"admin","age":18}
+```
+
+非法请求:
+
+```http
+POST /users
+Content-Type: application/json
+
+{"name":"tom","role":"admin"}
+```
+
+这个非法请求会被拦截,因为 OpenAPI 中定义了 query 参数 `source` 为必填。
diff --git a/go.mod b/go.mod
index 6dd6b8e9..e27fbe7d 100644
--- a/go.mod
+++ b/go.mod
@@ -38,6 +38,7 @@ 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/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.23.0
github.com/prometheus/common v0.65.0
@@ -61,6 +62,7 @@ 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
@@ -102,6 +104,7 @@ require (
github.com/aliyun/aliyun-secretsmanager-client-go v1.1.5 // indirect
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/beevik/etree v1.5.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.2.0 // indirect
@@ -205,6 +208,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/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
diff --git a/go.sum b/go.sum
index 562fde7a..8755c03f 100644
--- a/go.sum
+++ b/go.sum
@@ -180,6 +180,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.3.2/go.mod h1:72H
github.com/aws/aws-sdk-go-v2/service/sso v1.4.2/go.mod h1:NBvT9R1MEF+Ud6ApJKM0G+IkPchKS7p7c2YPKwHmBOk=
github.com/aws/aws-sdk-go-v2/service/sts v1.7.2/go.mod h1:8EzeIqfWt2wWT4rJVu3f21TfrhJ8AEMzVybRNSb/b4g=
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/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=
@@ -867,6 +869,12 @@ 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/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=
@@ -1205,6 +1213,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=
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=
diff --git a/pkg/filter/http/apiconfig/api/discovery_service.go b/pkg/filter/http/apiconfig/api/discovery_service.go
index f9c378d8..552ea853 100644
--- a/pkg/filter/http/apiconfig/api/discovery_service.go
+++ b/pkg/filter/http/apiconfig/api/discovery_service.go
@@ -35,6 +35,7 @@ type APIDiscoveryService interface {
InitAPIsFromConfig(apiConfig config.APIConfig) error
AddAPI(router.API) error
AddOrUpdateAPI(router.API) error
+ MergeAPI(router.API) error
ClearAPI() error
GetAPI(string, string) (router.API, error)
MatchAPI(string, string) (router.API, error)
@@ -65,6 +66,11 @@ func (l *LocalMemoryAPIDiscoveryService) AddOrUpdateAPI(api router.API) error {
return l.router.PutOrUpdateAPI(api)
}
+// MergeAPI merges metadata into an existing route without registering a new route.
+func (l *LocalMemoryAPIDiscoveryService) MergeAPI(api router.API) error {
+ return l.router.MergeAPI(api)
+}
+
// GetAPI returns the method to the caller
func (l *LocalMemoryAPIDiscoveryService) GetAPI(url string, httpVerb string) (router.API, error) {
if api, ok := l.router.FindAPI(url, httpVerb); ok {
diff --git a/pkg/filter/http/apiconfig/api_config.go b/pkg/filter/http/apiconfig/api_config.go
index 992c3b77..e48d72ba 100644
--- a/pkg/filter/http/apiconfig/api_config.go
+++ b/pkg/filter/http/apiconfig/api_config.go
@@ -17,6 +17,10 @@
package apiconfig
+import (
+ "os"
+)
+
import (
"github.com/pkg/errors"
)
@@ -27,6 +31,7 @@ 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"
@@ -69,17 +74,34 @@ 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.Dynamic {
server.GetApiConfigManager().AddApiConfigListener(factory.cfg.DynamicAdapter, factory)
return nil
}
+ 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
+ }
+
config, err := initApiConfig(factory.cfg)
if err != nil {
- logger.Errorf("Get ApiConfig fail: %v", err)
+ return err
}
if err := factory.apiService.InitAPIsFromConfig(*config); err != nil {
- logger.Errorf("InitAPIsFromConfig fail: %v", err)
+ return err
+ }
+
+ if factory.cfg.EnableOpenAPIValidation && factory.cfg.OpenAPIPath != "" {
+ if err := factory.mergeOpenAPIFromFile(factory.cfg.OpenAPIPath); err != nil {
+ return err
+ }
}
return nil
@@ -124,6 +146,15 @@ func (f *Filter) Decode(ctx *contexthttp.HttpContext) filter.FilterStatus {
logger.Debug(e.Error())
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
}
@@ -132,6 +163,37 @@ 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
new file mode 100644
index 00000000..ff6b4692
--- /dev/null
+++ b/pkg/filter/http/apiconfig/api_config_test.go
@@ -0,0 +1,240 @@
+/*
+ * 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 apiconfig
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+)
+
+import (
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+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)
+
+ filterInstance := &Filter{apiService: 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())
+}
+
+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)
+
+ filterInstance := &Filter{apiService: apiService}
+ req := httptest.NewRequest(http.MethodGet, "/users?id=123", 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)
+}
+
+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)
+
+ filterInstance := &Filter{apiService: apiService}
+ req := httptest.NewRequest(http.MethodGet, "/users?page=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())
+ 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(`
+name: api name
+resources:
+ - path: /users
+ type: restful
+ methods:
+ - httpVerb: POST
+ enable: true
+ resources:
+ - path: /:id
+ type: restful
+ methods:
+ - 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))
+ 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))
+ assert.Equal(t, "/users/:id", pathMatched.URLPattern)
+}
+
+func TestApply_RejectsDynamicOpenAPIValidationCombination(t *testing.T) {
+ factory := &FilterFactory{
+ cfg: &ApiConfigConfig{
+ Dynamic: true,
+ DynamicAdapter: "mock",
+ OpenAPIPath: "configs/openapi_users.yaml",
+ EnableOpenAPIValidation: true,
+ },
+ }
+
+ err := factory.Apply()
+ require.Error(t, err)
+ assert.ErrorContains(t, err, "dynamic")
+ assert.ErrorContains(t, err, "openapi")
+}
diff --git a/pkg/filter/http/apiconfig/config.go b/pkg/filter/http/apiconfig/config.go
index c08da2da..f7f2513e 100644
--- a/pkg/filter/http/apiconfig/config.go
+++ b/pkg/filter/http/apiconfig/config.go
@@ -23,8 +23,10 @@ import (
// ApiConfigConfig the config for api_config filter
type ApiConfigConfig struct {
- APIMetaConfig *model.APIMetaConfig `yaml:"api_meta_config" json:"api_meta_config,omitempty"`
- Path string `yaml:"path" json:"path,omitempty"`
- Dynamic bool `yaml:"dynamic" json:"dynamic,omitempty"`
- DynamicAdapter string `yaml:"dynamic_adapter" json:"dynamic_adapter,omitempty"`
+ APIMetaConfig *model.APIMetaConfig `yaml:"api_meta_config" json:"api_meta_config,omitempty"`
+ Path string `yaml:"path" json:"path,omitempty"`
+ Dynamic bool `yaml:"dynamic" json:"dynamic,omitempty"`
+ 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"`
}
diff --git a/pkg/filter/http/apiconfig/openapi/compiler.go b/pkg/filter/http/apiconfig/openapi/compiler.go
new file mode 100644
index 00000000..646f8a06
--- /dev/null
+++ b/pkg/filter/http/apiconfig/openapi/compiler.go
@@ -0,0 +1,284 @@
+/*
+ * 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
new file mode 100644
index 00000000..e43a5108
--- /dev/null
+++ b/pkg/filter/http/apiconfig/openapi/compiler_test.go
@@ -0,0 +1,203 @@
+/*
+ * 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/config.go b/pkg/filter/http/apiconfig/openapi/doc.go
similarity index 61%
copy from pkg/filter/http/apiconfig/config.go
copy to pkg/filter/http/apiconfig/openapi/doc.go
index c08da2da..b9bc011f 100644
--- a/pkg/filter/http/apiconfig/config.go
+++ b/pkg/filter/http/apiconfig/openapi/doc.go
@@ -15,16 +15,6 @@
* limitations under the License.
*/
-package apiconfig
-
-import (
- "github.com/apache/dubbo-go-pixiu/pkg/model"
-)
-
-// ApiConfigConfig the config for api_config filter
-type ApiConfigConfig struct {
- APIMetaConfig *model.APIMetaConfig `yaml:"api_meta_config" json:"api_meta_config,omitempty"`
- Path string `yaml:"path" json:"path,omitempty"`
- Dynamic bool `yaml:"dynamic" json:"dynamic,omitempty"`
- DynamicAdapter string `yaml:"dynamic_adapter" json:"dynamic_adapter,omitempty"`
-}
+// Package openapi provides OpenAPI parsing and compilation helpers for the
+// api config filter.
+package openapi
diff --git a/pkg/filter/http/apiconfig/config.go b/pkg/filter/http/apiconfig/openapi/metadata.go
similarity index 62%
copy from pkg/filter/http/apiconfig/config.go
copy to pkg/filter/http/apiconfig/openapi/metadata.go
index c08da2da..8e62c8d8 100644
--- a/pkg/filter/http/apiconfig/config.go
+++ b/pkg/filter/http/apiconfig/openapi/metadata.go
@@ -15,16 +15,23 @@
* limitations under the License.
*/
-package apiconfig
+package openapi
import (
- "github.com/apache/dubbo-go-pixiu/pkg/model"
+ "github.com/apache/dubbo-go-pixiu/pkg/router"
)
-// ApiConfigConfig the config for api_config filter
-type ApiConfigConfig struct {
- APIMetaConfig *model.APIMetaConfig `yaml:"api_meta_config" json:"api_meta_config,omitempty"`
- Path string `yaml:"path" json:"path,omitempty"`
- Dynamic bool `yaml:"dynamic" json:"dynamic,omitempty"`
- DynamicAdapter string `yaml:"dynamic_adapter" json:"dynamic_adapter,omitempty"`
+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/config.go b/pkg/filter/http/apiconfig/openapi/model.go
similarity index 51%
copy from pkg/filter/http/apiconfig/config.go
copy to pkg/filter/http/apiconfig/openapi/model.go
index c08da2da..afbf62a1 100644
--- a/pkg/filter/http/apiconfig/config.go
+++ b/pkg/filter/http/apiconfig/openapi/model.go
@@ -15,16 +15,41 @@
* limitations under the License.
*/
-package apiconfig
+package openapi
-import (
- "github.com/apache/dubbo-go-pixiu/pkg/model"
-)
+const ValidationPlanMetadataKey = "openapi.validation.plan"
-// ApiConfigConfig the config for api_config filter
-type ApiConfigConfig struct {
- APIMetaConfig *model.APIMetaConfig `yaml:"api_meta_config" json:"api_meta_config,omitempty"`
- Path string `yaml:"path" json:"path,omitempty"`
- Dynamic bool `yaml:"dynamic" json:"dynamic,omitempty"`
- DynamicAdapter string `yaml:"dynamic_adapter" json:"dynamic_adapter,omitempty"`
+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
new file mode 100644
index 00000000..5296ce77
--- /dev/null
+++ b/pkg/filter/http/apiconfig/openapi/validator.go
@@ -0,0 +1,292 @@
+/*
+ * 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
new file mode 100644
index 00000000..b2d57ef5
--- /dev/null
+++ b/pkg/filter/http/apiconfig/openapi/validator_test.go
@@ -0,0 +1,201 @@
+/*
+ * 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/router/api.go b/pkg/router/api.go
index c97870db..508c5651 100644
--- a/pkg/router/api.go
+++ b/pkg/router/api.go
@@ -32,6 +32,7 @@ type API struct {
URLPattern string `json:"urlPattern" yaml:"urlPattern"`
config.Method `json:"method,inline" yaml:"method,inline"`
Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty"`
+ Metadata map[string]any `json:"-" yaml:"-"`
}
// GetURIParams returns the values retrieved from the rawURL
diff --git a/pkg/router/route.go b/pkg/router/route.go
index 34fce504..5e1c9e29 100644
--- a/pkg/router/route.go
+++ b/pkg/router/route.go
@@ -41,6 +41,7 @@ type Node struct {
filters []string // nolint:unused
method *config.Method
headers map[string]string
+ metadata map[string]any
}
// Route defines the tree of router APIs
@@ -127,6 +128,7 @@ func (rt *Route) PutAPI(api API) error {
fullPath: lowerCasePath,
method: &api.Method,
headers: api.Headers,
+ metadata: api.Metadata,
}
rt.lock.Lock()
defer rt.lock.Unlock()
@@ -147,6 +149,7 @@ func (rt *Route) PutOrUpdateAPI(api API) error {
fullPath: lowerCasePath,
method: &api.Method,
headers: api.Headers,
+ metadata: api.Metadata,
}
rt.lock.Lock()
defer rt.lock.Unlock()
@@ -178,6 +181,44 @@ func (rt *Route) PutOrUpdateAPI(api API) error {
return nil
}
+// MergeAPI replaces the metadata of an existing api without creating a new route.
+func (rt *Route) MergeAPI(api API) error {
+ lowerCasePath := strings.ToLower(api.URLPattern)
+ key := getTrieKey(api.HTTPVerb, lowerCasePath, false)
+
+ rt.lock.Lock()
+ defer rt.lock.Unlock()
+
+ node, _, exists, err := rt.tree.Get(key)
+ if err != nil {
+ return err
+ }
+ if !exists {
+ return errors.Errorf(
+ "Method %s with address %s does not exist in path %s",
+ api.HTTPVerb, lowerCasePath, lowerCasePath,
+ )
+ }
+
+ bizInfoInterface := node.GetBizInfo()
+ bizInfo, ok := bizInfoInterface.(*Node)
+ if bizInfo == nil || !ok {
+ return errors.New("bizInfoInterface.(*Node) failed")
+ }
+
+ if len(api.Metadata) == 0 {
+ return nil
+ }
+ if bizInfo.metadata == nil {
+ bizInfo.metadata = make(map[string]any, len(api.Metadata))
+ }
+ for key, value := range api.Metadata {
+ bizInfo.metadata[key] = value
+ }
+
+ return nil
+}
+
// FindAPI return if api has path in trie,or nil
func (rt *Route) FindAPI(fullPath string, httpverb string) (*API, bool) {
lowerCasePath := strings.ToLower(fullPath)
@@ -189,6 +230,7 @@ func (rt *Route) FindAPI(fullPath string, httpverb string) (*API, bool) {
URLPattern: n.fullPath,
Method: *n.method,
Headers: n.headers,
+ Metadata: n.metadata,
}, found
}
return nil, false
@@ -205,6 +247,7 @@ func (rt *Route) MatchAPI(fullPath string, httpverb string) (*API, bool) {
URLPattern: n.fullPath,
Method: *n.method,
Headers: n.headers,
+ Metadata: n.metadata,
}, found
}
return nil, false