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