merged with latest master
This commit is contained in:
41
CONFIG.md
41
CONFIG.md
@@ -547,7 +547,38 @@ schemaValidator:
|
||||
|
||||
---
|
||||
|
||||
#### 5. Sign Validator Plugin
|
||||
#### 5. Schema2Validator Plugin
|
||||
|
||||
**Purpose**: Validate requests against OpenAPI 3.x specifications with action-based matching.
|
||||
|
||||
```yaml
|
||||
schemaValidator:
|
||||
id: schemav2validator
|
||||
config:
|
||||
type: url
|
||||
location: https://raw.githubusercontent.com/beckn/protocol-specifications-new/refs/heads/draft/api-specs/beckn-protocol-api.yaml
|
||||
cacheTTL: "3600"
|
||||
```
|
||||
|
||||
**Or for local files:**
|
||||
|
||||
```yaml
|
||||
schemaValidator:
|
||||
id: schemav2validator
|
||||
config:
|
||||
type: file
|
||||
location: ./validation-scripts/l2-config/mobility_1.1.0_openapi_3.1.yaml
|
||||
cacheTTL: "3600"
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
- `type`: Source type - `"url"` for remote specs, `"file"` for local files
|
||||
- `location`: URL or file path to OpenAPI 3.1 specification
|
||||
- `cacheTTL`: Cache TTL in seconds before reloading spec (default: `"3600"`)
|
||||
|
||||
---
|
||||
|
||||
#### 6. Sign Validator Plugin
|
||||
|
||||
**Purpose**: Validate Ed25519 digital signatures on incoming requests.
|
||||
|
||||
@@ -560,7 +591,7 @@ signValidator:
|
||||
|
||||
---
|
||||
|
||||
#### 6. Router Plugin
|
||||
#### 7. Router Plugin
|
||||
|
||||
**Purpose**: Determine routing destination based on rules.
|
||||
|
||||
@@ -585,7 +616,7 @@ router:
|
||||
|
||||
---
|
||||
|
||||
#### 7. Signer Plugin
|
||||
#### 8. Signer Plugin
|
||||
|
||||
**Purpose**: Sign outgoing requests with Ed25519 signature.
|
||||
|
||||
@@ -598,7 +629,7 @@ signer:
|
||||
|
||||
---
|
||||
|
||||
#### 8. Publisher Plugin
|
||||
#### 9. Publisher Plugin
|
||||
|
||||
**Purpose**: Publish messages to RabbitMQ or Pub/Sub for asynchronous processing.
|
||||
|
||||
@@ -616,7 +647,7 @@ publisher:
|
||||
|
||||
---
|
||||
|
||||
#### 9. Middleware Plugin
|
||||
#### 10. Middleware Plugin
|
||||
|
||||
**Purpose**: Request preprocessing like UUID generation and header manipulation.
|
||||
|
||||
|
||||
@@ -140,7 +140,8 @@ Resources:
|
||||
- **Router**: YAML-based routing rules engine for request forwarding
|
||||
- **Signer**: Ed25519 digital signature creation for outgoing requests
|
||||
- **SignValidator**: Ed25519 signature validation for incoming requests
|
||||
- **SchemaValidator**: JSON schema validation
|
||||
- **SchemaValidator**: JSON schema validation
|
||||
- **Schemav2Validator**: OpenAPI 3.x schema validation with action-based matching
|
||||
- **KeyManager**: HashiCorp Vault integration for production key management
|
||||
- **SimpleKeyManager**: Embedded key management for local development (no external dependencies)
|
||||
- **Publisher**: RabbitMQ message publishing for asynchronous processing
|
||||
@@ -324,9 +325,11 @@ modules:
|
||||
config:
|
||||
routingConfig: ./config/routing.yaml
|
||||
schemaValidator:
|
||||
id: schemavalidator
|
||||
id: schemavalidator # or schemav2validator
|
||||
config:
|
||||
schemaDir: ./schemas
|
||||
schemaDir: ./schemas # for schemavalidator
|
||||
# type: url # for schemav2validator
|
||||
# location: https://example.com/spec.yaml
|
||||
steps:
|
||||
- validateSign
|
||||
- addRoute
|
||||
|
||||
22
SETUP.md
22
SETUP.md
@@ -830,6 +830,28 @@ schemaValidator:
|
||||
schemaURL: https://schemas.beckn.org
|
||||
```
|
||||
|
||||
#### Schema2Validator Plugin
|
||||
|
||||
**Remote URL Configuration:**
|
||||
```yaml
|
||||
schemaValidator:
|
||||
id: schemav2validator
|
||||
config:
|
||||
type: url
|
||||
location: https://raw.githubusercontent.com/beckn/protocol-specifications-new/refs/heads/draft/api-specs/beckn-protocol-api.yaml
|
||||
cacheTTL: "3600"
|
||||
```
|
||||
|
||||
**Local File Configuration:**
|
||||
```yaml
|
||||
schemaValidator:
|
||||
id: schemav2validator
|
||||
config:
|
||||
type: file
|
||||
location: ./schemas/beckn-protocol-api.yaml
|
||||
cacheTTL: "3600"
|
||||
```
|
||||
|
||||
#### Router Plugin
|
||||
```yaml
|
||||
router:
|
||||
|
||||
12
go.mod
12
go.mod
@@ -29,6 +29,8 @@ require (
|
||||
github.com/go-jose/go-jose/v4 v4.0.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
|
||||
@@ -36,18 +38,24 @@ require (
|
||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
|
||||
github.com/hashicorp/go-sockaddr v1.0.2 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/prometheus/client_model v0.6.0 // indirect
|
||||
github.com/prometheus/common v0.45.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/redis/go-redis/extra/rediscmd/v9 v9.16.0 // indirect
|
||||
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||
github.com/woodsbury/decimal128 v1.3.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/runtime v0.63.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
@@ -56,6 +64,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/getkin/kin-openapi v0.133.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7
|
||||
github.com/hashicorp/vault/api v1.16.0
|
||||
@@ -64,6 +73,7 @@ require (
|
||||
github.com/redis/go-redis/extra/redisotel/v9 v9.16.0
|
||||
github.com/redis/go-redis/v9 v9.16.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
go.opentelemetry.io/contrib/instrumentation/runtime v0.63.0
|
||||
go.opentelemetry.io/otel v1.38.0
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.46.0
|
||||
go.opentelemetry.io/otel/metric v1.38.0
|
||||
|
||||
33
go.sum
33
go.sum
@@ -2,6 +2,8 @@ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
@@ -10,11 +12,15 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
@@ -22,6 +28,8 @@ github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cn
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||
github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U=
|
||||
github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -29,8 +37,12 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw=
|
||||
github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
@@ -61,10 +73,14 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hashicorp/vault/api v1.16.0 h1:nbEYGJiAPGzT9U4oWgaaB0g+Rj8E59QuHKyA5LhwQN4=
|
||||
github.com/hashicorp/vault/api v1.16.0/go.mod h1:KhuUhzOD8lDSk29AtzNjgAu2kxRA9jL9NAbkFlqvkBA=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
@@ -82,6 +98,14 @@ github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUb
|
||||
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
@@ -119,6 +143,10 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
|
||||
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
|
||||
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
|
||||
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
|
||||
github.com/zenazn/pkcs7pad v0.0.0-20170308005700-253a5b1f0e03 h1:m1h+vudopHsI67FPT9MOncyndWhTcdUoBtI1R1uajGY=
|
||||
github.com/zenazn/pkcs7pad v0.0.0-20170308005700-253a5b1f0e03/go.mod h1:8sheVFH84v3PCyFY/O02mIgSQY9I6wMYPWsq7mDnEZY=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
@@ -165,3 +193,4 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ plugins=(
|
||||
"otelmetrics"
|
||||
"router"
|
||||
"schemavalidator"
|
||||
"schemav2validator"
|
||||
"signer"
|
||||
"signvalidator"
|
||||
)
|
||||
|
||||
138
pkg/plugin/implementation/schemav2validator/README.md
Normal file
138
pkg/plugin/implementation/schemav2validator/README.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Schemav2Validator Plugin
|
||||
|
||||
Validates Beckn protocol requests against OpenAPI 3.1 specifications using kin-openapi library.
|
||||
|
||||
## Features
|
||||
|
||||
- Validates requests against OpenAPI 3.1 specs
|
||||
- Supports remote URL and local file loading
|
||||
- Automatic external $ref resolution
|
||||
- TTL-based caching with automatic refresh
|
||||
- Generic path matching (no hardcoded paths)
|
||||
- Direct schema validation without router overhead
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
schemaValidator:
|
||||
id: schemav2validator
|
||||
config:
|
||||
type: url
|
||||
location: https://example.com/openapi-spec.yaml
|
||||
cacheTTL: "3600"
|
||||
```
|
||||
|
||||
### Configuration Parameters
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `type` | string | Yes | - | Type of spec source: "url" or "file" ("dir" reserved for future) |
|
||||
| `location` | string | Yes | - | URL or file path to OpenAPI 3.1 spec |
|
||||
| `cacheTTL` | string | No | "3600" | Cache TTL in seconds before reloading spec |
|
||||
|
||||
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Load Spec**: Loads OpenAPI spec from configured URL at startup
|
||||
2. **Extract Action**: Extracts `action` from request `context.action` field
|
||||
3. **Find Schema**: Searches all paths and HTTP methods in spec for schema with matching action:
|
||||
- Checks `properties.context.action.enum` for the action value
|
||||
- Also checks `properties.context.allOf[].properties.action.enum`
|
||||
- Stops at first match
|
||||
4. **Validate**: Validates request body against matched schema using `Schema.VisitJSON()` with:
|
||||
- Required fields validation
|
||||
- Data type validation (string, number, boolean, object, array)
|
||||
- Format validation (email, uri, date-time, uuid, etc.)
|
||||
- Constraint validation (min/max, pattern, enum, const)
|
||||
- Nested object and array validation
|
||||
5. **Return Errors**: Returns validation errors in ONIX format
|
||||
|
||||
## Action-Based Matching
|
||||
|
||||
The validator uses action-based schema matching, not URL path matching. It searches for schemas where the `context.action` field has an enum constraint containing the request's action value.
|
||||
|
||||
### Example OpenAPI Schema
|
||||
|
||||
```yaml
|
||||
paths:
|
||||
/beckn/search:
|
||||
post:
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
context:
|
||||
properties:
|
||||
action:
|
||||
enum: ["search"] # ← Matches action="search"
|
||||
```
|
||||
|
||||
### Matching Examples
|
||||
|
||||
| Request Action | Schema Enum | Match |
|
||||
|----------------|-------------|-------|
|
||||
| `search` | `enum: ["search"]` | ✅ Matches |
|
||||
| `select` | `enum: ["select", "init"]` | ✅ Matches |
|
||||
| `discover` | `enum: ["search"]` | ❌ No match |
|
||||
| `on_search` | `enum: ["on_search"]` | ✅ Matches |
|
||||
|
||||
## External References
|
||||
|
||||
The validator automatically resolves external `$ref` references in OpenAPI specs:
|
||||
|
||||
```yaml
|
||||
# Main spec at https://example.com/api.yaml
|
||||
paths:
|
||||
/search:
|
||||
post:
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: 'https://example.com/schemas/search.yaml#/SearchRequest'
|
||||
```
|
||||
|
||||
The loader will automatically fetch and resolve the external reference.
|
||||
|
||||
## Example Usage
|
||||
|
||||
### Remote URL
|
||||
|
||||
```yaml
|
||||
schemaValidator:
|
||||
id: schemav2validator
|
||||
config:
|
||||
type: url
|
||||
location: https://raw.githubusercontent.com/beckn/protocol-specifications/master/api/beckn-2.0.0.yaml
|
||||
cacheTTL: "7200"
|
||||
```
|
||||
|
||||
### Local File
|
||||
|
||||
```yaml
|
||||
schemaValidator:
|
||||
id: schemav2validator
|
||||
config:
|
||||
type: file
|
||||
location: ./validation-scripts/l2-config/mobility_1.1.0_openapi_3.1.yaml
|
||||
cacheTTL: "3600"
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `github.com/getkin/kin-openapi` - OpenAPI 3 parser and validator
|
||||
|
||||
## Error Messages
|
||||
|
||||
| Scenario | Error Message |
|
||||
|----------|---------------|
|
||||
| Action is number | `"failed to parse JSON payload: json: cannot unmarshal number into Go struct field .context.action of type string"` |
|
||||
| Action is empty | `"missing field Action in context"` |
|
||||
| Action not in spec | `"unsupported action: <action>"` |
|
||||
| Invalid URL | `"Invalid URL or unreachable: <url>"` |
|
||||
| Schema validation fails | Returns detailed field-level errors |
|
||||
|
||||
47
pkg/plugin/implementation/schemav2validator/cmd/plugin.go
Normal file
47
pkg/plugin/implementation/schemav2validator/cmd/plugin.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/beckn-one/beckn-onix/pkg/plugin/definition"
|
||||
"github.com/beckn-one/beckn-onix/pkg/plugin/implementation/schemav2validator"
|
||||
)
|
||||
|
||||
// schemav2ValidatorProvider provides instances of schemav2Validator.
|
||||
type schemav2ValidatorProvider struct{}
|
||||
|
||||
// New initialises a new Schemav2Validator instance.
|
||||
func (vp schemav2ValidatorProvider) New(ctx context.Context, config map[string]string) (definition.SchemaValidator, func() error, error) {
|
||||
if ctx == nil {
|
||||
return nil, nil, errors.New("context cannot be nil")
|
||||
}
|
||||
|
||||
typeVal, hasType := config["type"]
|
||||
locVal, hasLoc := config["location"]
|
||||
|
||||
if !hasType || typeVal == "" {
|
||||
return nil, nil, errors.New("type not configured")
|
||||
}
|
||||
if !hasLoc || locVal == "" {
|
||||
return nil, nil, errors.New("location not configured")
|
||||
}
|
||||
|
||||
cfg := &schemav2validator.Config{
|
||||
Type: typeVal,
|
||||
Location: locVal,
|
||||
CacheTTL: 3600,
|
||||
}
|
||||
|
||||
if ttlStr, ok := config["cacheTTL"]; ok {
|
||||
if ttl, err := strconv.Atoi(ttlStr); err == nil && ttl > 0 {
|
||||
cfg.CacheTTL = ttl
|
||||
}
|
||||
}
|
||||
|
||||
return schemav2validator.New(ctx, cfg)
|
||||
}
|
||||
|
||||
// Provider is the exported plugin provider.
|
||||
var Provider schemav2ValidatorProvider
|
||||
184
pkg/plugin/implementation/schemav2validator/cmd/plugin_test.go
Normal file
184
pkg/plugin/implementation/schemav2validator/cmd/plugin_test.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testSpec = `openapi: 3.1.0
|
||||
info:
|
||||
title: Test API
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/test:
|
||||
post:
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
context:
|
||||
type: object
|
||||
properties:
|
||||
action:
|
||||
const: test
|
||||
`
|
||||
|
||||
func TestProvider_New(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(testSpec))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
config map[string]string
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "nil context",
|
||||
ctx: nil,
|
||||
config: map[string]string{"url": server.URL},
|
||||
wantErr: true,
|
||||
errMsg: "context cannot be nil",
|
||||
},
|
||||
{
|
||||
name: "missing type",
|
||||
ctx: context.Background(),
|
||||
config: map[string]string{"location": server.URL},
|
||||
wantErr: true,
|
||||
errMsg: "type not configured",
|
||||
},
|
||||
{
|
||||
name: "missing location",
|
||||
ctx: context.Background(),
|
||||
config: map[string]string{"type": "url"},
|
||||
wantErr: true,
|
||||
errMsg: "location not configured",
|
||||
},
|
||||
{
|
||||
name: "empty type",
|
||||
ctx: context.Background(),
|
||||
config: map[string]string{"type": "", "location": server.URL},
|
||||
wantErr: true,
|
||||
errMsg: "type not configured",
|
||||
},
|
||||
{
|
||||
name: "empty location",
|
||||
ctx: context.Background(),
|
||||
config: map[string]string{"type": "url", "location": ""},
|
||||
wantErr: true,
|
||||
errMsg: "location not configured",
|
||||
},
|
||||
{
|
||||
name: "valid config with default TTL",
|
||||
ctx: context.Background(),
|
||||
config: map[string]string{"type": "url", "location": server.URL},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid config with custom TTL",
|
||||
ctx: context.Background(),
|
||||
config: map[string]string{
|
||||
"type": "url",
|
||||
"location": server.URL,
|
||||
"cacheTTL": "7200",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid file type",
|
||||
ctx: context.Background(),
|
||||
config: map[string]string{
|
||||
"type": "file",
|
||||
"location": "/tmp/spec.yaml",
|
||||
},
|
||||
wantErr: true, // file doesn't exist
|
||||
},
|
||||
{
|
||||
name: "invalid TTL falls back to default",
|
||||
ctx: context.Background(),
|
||||
config: map[string]string{
|
||||
"type": "url",
|
||||
"location": server.URL,
|
||||
"cacheTTL": "invalid",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "negative TTL falls back to default",
|
||||
ctx: context.Background(),
|
||||
config: map[string]string{
|
||||
"type": "url",
|
||||
"location": server.URL,
|
||||
"cacheTTL": "-100",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "zero TTL falls back to default",
|
||||
ctx: context.Background(),
|
||||
config: map[string]string{
|
||||
"type": "url",
|
||||
"location": server.URL,
|
||||
"cacheTTL": "0",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
provider := schemav2ValidatorProvider{}
|
||||
validator, cleanup, err := provider.New(tt.ctx, tt.config)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if tt.wantErr && tt.errMsg != "" && err != nil {
|
||||
if !contains(err.Error(), tt.errMsg) {
|
||||
t.Errorf("New() error = %v, want error containing %v", err, tt.errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
if validator == nil {
|
||||
t.Error("Expected validator instance, got nil")
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Error("Expected nil cleanup function, got non-nil")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_ExportedVariable(t *testing.T) {
|
||||
if Provider == (schemav2ValidatorProvider{}) {
|
||||
t.Log("Provider variable is properly exported")
|
||||
} else {
|
||||
t.Error("Provider variable has unexpected value")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
if len(substr) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(s) < len(substr) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
362
pkg/plugin/implementation/schemav2validator/schemav2validator.go
Normal file
362
pkg/plugin/implementation/schemav2validator/schemav2validator.go
Normal file
@@ -0,0 +1,362 @@
|
||||
package schemav2validator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/beckn-one/beckn-onix/pkg/log"
|
||||
"github.com/beckn-one/beckn-onix/pkg/model"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
)
|
||||
|
||||
// payload represents the structure of the data payload with context information.
|
||||
type payload struct {
|
||||
Context struct {
|
||||
Action string `json:"action"`
|
||||
} `json:"context"`
|
||||
}
|
||||
|
||||
// schemav2Validator implements the SchemaValidator interface.
|
||||
type schemav2Validator struct {
|
||||
config *Config
|
||||
spec *cachedSpec
|
||||
specMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// cachedSpec holds a cached OpenAPI spec.
|
||||
type cachedSpec struct {
|
||||
doc *openapi3.T
|
||||
actionSchemas map[string]*openapi3.SchemaRef // O(1) action lookup
|
||||
loadedAt time.Time
|
||||
}
|
||||
|
||||
// Config struct for Schemav2Validator.
|
||||
type Config struct {
|
||||
Type string // "url", "file", or "dir"
|
||||
Location string // URL, file path, or directory path
|
||||
CacheTTL int
|
||||
}
|
||||
|
||||
// New creates a new Schemav2Validator instance.
|
||||
func New(ctx context.Context, config *Config) (*schemav2Validator, func() error, error) {
|
||||
if config == nil {
|
||||
return nil, nil, fmt.Errorf("config cannot be nil")
|
||||
}
|
||||
if config.Type == "" {
|
||||
return nil, nil, fmt.Errorf("config type cannot be empty")
|
||||
}
|
||||
if config.Location == "" {
|
||||
return nil, nil, fmt.Errorf("config location cannot be empty")
|
||||
}
|
||||
if config.Type != "url" && config.Type != "file" && config.Type != "dir" {
|
||||
return nil, nil, fmt.Errorf("config type must be 'url', 'file', or 'dir'")
|
||||
}
|
||||
|
||||
if config.CacheTTL == 0 {
|
||||
config.CacheTTL = 3600
|
||||
}
|
||||
|
||||
v := &schemav2Validator{
|
||||
config: config,
|
||||
}
|
||||
|
||||
if err := v.initialise(ctx); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to initialise schemav2Validator: %v", err)
|
||||
}
|
||||
|
||||
go v.refreshLoop(ctx)
|
||||
|
||||
return v, nil, nil
|
||||
}
|
||||
|
||||
// Validate validates the given data against the OpenAPI schema.
|
||||
func (v *schemav2Validator) Validate(ctx context.Context, reqURL *url.URL, data []byte) error {
|
||||
var payloadData payload
|
||||
err := json.Unmarshal(data, &payloadData)
|
||||
if err != nil {
|
||||
return model.NewBadReqErr(fmt.Errorf("failed to parse JSON payload: %v", err))
|
||||
}
|
||||
|
||||
if payloadData.Context.Action == "" {
|
||||
return model.NewBadReqErr(fmt.Errorf("missing field Action in context"))
|
||||
}
|
||||
|
||||
v.specMutex.RLock()
|
||||
spec := v.spec
|
||||
v.specMutex.RUnlock()
|
||||
|
||||
if spec == nil || spec.doc == nil {
|
||||
return model.NewBadReqErr(fmt.Errorf("no OpenAPI spec loaded"))
|
||||
}
|
||||
|
||||
action := payloadData.Context.Action
|
||||
|
||||
// O(1) lookup from action index
|
||||
schema := spec.actionSchemas[action]
|
||||
if schema == nil || schema.Value == nil {
|
||||
return model.NewBadReqErr(fmt.Errorf("unsupported action: %s", action))
|
||||
}
|
||||
|
||||
log.Debugf(ctx, "Validating action: %s", action)
|
||||
|
||||
var jsonData any
|
||||
if err := json.Unmarshal(data, &jsonData); err != nil {
|
||||
return model.NewBadReqErr(fmt.Errorf("invalid JSON: %v", err))
|
||||
}
|
||||
|
||||
opts := []openapi3.SchemaValidationOption{
|
||||
openapi3.VisitAsRequest(),
|
||||
openapi3.EnableFormatValidation(),
|
||||
}
|
||||
if err := schema.Value.VisitJSON(jsonData, opts...); err != nil {
|
||||
log.Debugf(ctx, "Schema validation failed: %v", err)
|
||||
return v.formatValidationError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// initialise loads the OpenAPI spec from the configuration.
|
||||
func (v *schemav2Validator) initialise(ctx context.Context) error {
|
||||
return v.loadSpec(ctx)
|
||||
}
|
||||
|
||||
// loadSpec loads the OpenAPI spec from URL or local path.
|
||||
func (v *schemav2Validator) loadSpec(ctx context.Context) error {
|
||||
loader := openapi3.NewLoader()
|
||||
|
||||
// Allow external references
|
||||
loader.IsExternalRefsAllowed = true
|
||||
|
||||
var doc *openapi3.T
|
||||
var err error
|
||||
|
||||
switch v.config.Type {
|
||||
case "url":
|
||||
u, parseErr := url.Parse(v.config.Location)
|
||||
if parseErr != nil {
|
||||
return fmt.Errorf("failed to parse URL: %v", parseErr)
|
||||
}
|
||||
doc, err = loader.LoadFromURI(u)
|
||||
case "file":
|
||||
doc, err = loader.LoadFromFile(v.config.Location)
|
||||
case "dir":
|
||||
return fmt.Errorf("directory loading not yet implemented")
|
||||
default:
|
||||
return fmt.Errorf("unsupported type: %s", v.config.Type)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, err, "Failed to load from %s: %s", v.config.Type, v.config.Location)
|
||||
return fmt.Errorf("failed to load OpenAPI document: %v", err)
|
||||
}
|
||||
|
||||
// Validate spec (skip strict validation to allow JSON Schema keywords)
|
||||
if err := doc.Validate(ctx); err != nil {
|
||||
log.Debugf(ctx, "Spec validation warnings (non-fatal): %v", err)
|
||||
} else {
|
||||
log.Debugf(ctx, "Spec validation passed")
|
||||
}
|
||||
|
||||
// Build action→schema index for O(1) lookup
|
||||
actionSchemas := v.buildActionIndex(ctx, doc)
|
||||
|
||||
v.specMutex.Lock()
|
||||
v.spec = &cachedSpec{
|
||||
doc: doc,
|
||||
actionSchemas: actionSchemas,
|
||||
loadedAt: time.Now(),
|
||||
}
|
||||
v.specMutex.Unlock()
|
||||
|
||||
log.Debugf(ctx, "Loaded OpenAPI spec from %s: %s with %d actions indexed", v.config.Type, v.config.Location, len(actionSchemas))
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshLoop periodically reloads expired specs based on TTL.
|
||||
func (v *schemav2Validator) refreshLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(time.Duration(v.config.CacheTTL) * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
v.reloadExpiredSpec(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reloadExpiredSpec reloads spec if it has exceeded its TTL.
|
||||
func (v *schemav2Validator) reloadExpiredSpec(ctx context.Context) {
|
||||
v.specMutex.RLock()
|
||||
if v.spec == nil {
|
||||
v.specMutex.RUnlock()
|
||||
return
|
||||
}
|
||||
needsReload := time.Since(v.spec.loadedAt) >= time.Duration(v.config.CacheTTL)*time.Second
|
||||
v.specMutex.RUnlock()
|
||||
|
||||
if needsReload {
|
||||
if err := v.loadSpec(ctx); err != nil {
|
||||
log.Errorf(ctx, err, "Failed to reload spec")
|
||||
} else {
|
||||
log.Debugf(ctx, "Reloaded spec from %s: %s", v.config.Type, v.config.Location)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formatValidationError converts kin-openapi validation errors to ONIX error format.
|
||||
func (v *schemav2Validator) formatValidationError(err error) error {
|
||||
var schemaErrors []model.Error
|
||||
|
||||
// Check if it's a MultiError (collection of errors)
|
||||
if multiErr, ok := err.(openapi3.MultiError); ok {
|
||||
for _, e := range multiErr {
|
||||
v.extractSchemaErrors(e, &schemaErrors)
|
||||
}
|
||||
} else {
|
||||
v.extractSchemaErrors(err, &schemaErrors)
|
||||
}
|
||||
|
||||
return &model.SchemaValidationErr{Errors: schemaErrors}
|
||||
}
|
||||
|
||||
// extractSchemaErrors recursively extracts detailed error information from SchemaError.
|
||||
func (v *schemav2Validator) extractSchemaErrors(err error, schemaErrors *[]model.Error) {
|
||||
if schemaErr, ok := err.(*openapi3.SchemaError); ok {
|
||||
// Extract path from current error and message from Origin if available
|
||||
pathParts := schemaErr.JSONPointer()
|
||||
path := strings.Join(pathParts, "/")
|
||||
if path == "" {
|
||||
path = schemaErr.SchemaField
|
||||
}
|
||||
|
||||
message := schemaErr.Reason
|
||||
if schemaErr.Origin != nil {
|
||||
originMsg := schemaErr.Origin.Error()
|
||||
// Extract specific field error from nested message
|
||||
if strings.Contains(originMsg, "Error at \"/") {
|
||||
// Find last "Error at" which has the specific field error
|
||||
parts := strings.Split(originMsg, "Error at \"")
|
||||
if len(parts) > 1 {
|
||||
lastPart := parts[len(parts)-1]
|
||||
// Extract field path and update both path and message
|
||||
if idx := strings.Index(lastPart, "\":"); idx > 0 {
|
||||
fieldPath := lastPart[:idx]
|
||||
fieldMsg := strings.TrimSpace(lastPart[idx+2:])
|
||||
path = strings.TrimPrefix(fieldPath, "/")
|
||||
message = fieldMsg
|
||||
}
|
||||
}
|
||||
} else {
|
||||
message = originMsg
|
||||
}
|
||||
}
|
||||
|
||||
*schemaErrors = append(*schemaErrors, model.Error{
|
||||
Paths: path,
|
||||
Message: message,
|
||||
})
|
||||
} else if multiErr, ok := err.(openapi3.MultiError); ok {
|
||||
// Nested MultiError
|
||||
for _, e := range multiErr {
|
||||
v.extractSchemaErrors(e, schemaErrors)
|
||||
}
|
||||
} else {
|
||||
// Generic error
|
||||
*schemaErrors = append(*schemaErrors, model.Error{
|
||||
Paths: "",
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// buildActionIndex builds a map of action→schema for O(1) lookup.
|
||||
func (v *schemav2Validator) buildActionIndex(ctx context.Context, doc *openapi3.T) map[string]*openapi3.SchemaRef {
|
||||
actionSchemas := make(map[string]*openapi3.SchemaRef)
|
||||
|
||||
for path, item := range doc.Paths.Map() {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
// Check all HTTP methods
|
||||
for _, op := range []*openapi3.Operation{item.Post, item.Get, item.Put, item.Patch, item.Delete} {
|
||||
if op == nil || op.RequestBody == nil || op.RequestBody.Value == nil {
|
||||
continue
|
||||
}
|
||||
content := op.RequestBody.Value.Content.Get("application/json")
|
||||
if content == nil || content.Schema == nil || content.Schema.Value == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract action from schema
|
||||
action := v.extractActionFromSchema(content.Schema.Value)
|
||||
if action != "" {
|
||||
actionSchemas[action] = content.Schema
|
||||
log.Debugf(ctx, "Indexed action '%s' from path %s", action, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return actionSchemas
|
||||
}
|
||||
|
||||
// extractActionFromSchema extracts the action value from a schema.
|
||||
func (v *schemav2Validator) extractActionFromSchema(schema *openapi3.Schema) string {
|
||||
// Check direct properties
|
||||
if ctxProp := schema.Properties["context"]; ctxProp != nil && ctxProp.Value != nil {
|
||||
if action := v.getActionValue(ctxProp.Value); action != "" {
|
||||
return action
|
||||
}
|
||||
}
|
||||
|
||||
// Check allOf at schema level
|
||||
for _, allOfSchema := range schema.AllOf {
|
||||
if allOfSchema.Value != nil {
|
||||
if ctxProp := allOfSchema.Value.Properties["context"]; ctxProp != nil && ctxProp.Value != nil {
|
||||
if action := v.getActionValue(ctxProp.Value); action != "" {
|
||||
return action
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// getActionValue extracts action value from context schema.
|
||||
func (v *schemav2Validator) getActionValue(contextSchema *openapi3.Schema) string {
|
||||
if actionProp := contextSchema.Properties["action"]; actionProp != nil && actionProp.Value != nil {
|
||||
// Check const field
|
||||
if constVal, ok := actionProp.Value.Extensions["const"]; ok {
|
||||
if action, ok := constVal.(string); ok {
|
||||
return action
|
||||
}
|
||||
}
|
||||
// Check enum field (return first value)
|
||||
if len(actionProp.Value.Enum) > 0 {
|
||||
if action, ok := actionProp.Value.Enum[0].(string); ok {
|
||||
return action
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check allOf in context
|
||||
for _, allOfSchema := range contextSchema.AllOf {
|
||||
if allOfSchema.Value != nil {
|
||||
if action := v.getActionValue(allOfSchema.Value); action != "" {
|
||||
return action
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package schemav2validator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testSpec = `openapi: 3.1.0
|
||||
info:
|
||||
title: Test API
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/search:
|
||||
post:
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [context, message]
|
||||
properties:
|
||||
context:
|
||||
type: object
|
||||
required: [action]
|
||||
properties:
|
||||
action:
|
||||
const: search
|
||||
domain:
|
||||
type: string
|
||||
message:
|
||||
type: object
|
||||
/select:
|
||||
post:
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [context, message]
|
||||
properties:
|
||||
context:
|
||||
allOf:
|
||||
- type: object
|
||||
properties:
|
||||
action:
|
||||
enum: [select]
|
||||
message:
|
||||
type: object
|
||||
required: [order]
|
||||
properties:
|
||||
order:
|
||||
type: object
|
||||
`
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
wantErr bool
|
||||
}{
|
||||
{"nil config", nil, true},
|
||||
{"empty type", &Config{Type: "", Location: "http://example.com"}, true},
|
||||
{"empty location", &Config{Type: "url", Location: ""}, true},
|
||||
{"invalid type", &Config{Type: "invalid", Location: "http://example.com"}, true},
|
||||
{"invalid URL", &Config{Type: "url", Location: "http://invalid-domain-12345.com/spec.yaml"}, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, _, err := New(context.Background(), tt.config)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_ActionExtraction(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(testSpec))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
validator, _, err := New(context.Background(), &Config{Type: "url", Location: server.URL, CacheTTL: 3600})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create validator: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
payload string
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid search action",
|
||||
payload: `{"context":{"action":"search","domain":"retail"},"message":{}}`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid select action with allOf",
|
||||
payload: `{"context":{"action":"select"},"message":{"order":{}}}`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing action",
|
||||
payload: `{"context":{},"message":{}}`,
|
||||
wantErr: true,
|
||||
errMsg: "missing field Action",
|
||||
},
|
||||
{
|
||||
name: "unsupported action",
|
||||
payload: `{"context":{"action":"unknown"},"message":{}}`,
|
||||
wantErr: true,
|
||||
errMsg: "unsupported action: unknown",
|
||||
},
|
||||
{
|
||||
name: "action as number",
|
||||
payload: `{"context":{"action":123},"message":{}}`,
|
||||
wantErr: true,
|
||||
errMsg: "failed to parse JSON payload",
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
payload: `{invalid json}`,
|
||||
wantErr: true,
|
||||
errMsg: "failed to parse JSON payload",
|
||||
},
|
||||
{
|
||||
name: "missing required field",
|
||||
payload: `{"context":{"action":"search"}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validator.Validate(context.Background(), nil, []byte(tt.payload))
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr && tt.errMsg != "" && err != nil {
|
||||
if !contains(err.Error(), tt.errMsg) {
|
||||
t.Errorf("Validate() error = %v, want error containing %v", err, tt.errMsg)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_NestedValidation(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(testSpec))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
validator, _, err := New(context.Background(), &Config{Type: "url", Location: server.URL, CacheTTL: 3600})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create validator: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
payload string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "select missing required order",
|
||||
payload: `{"context":{"action":"select"},"message":{}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "select with order",
|
||||
payload: `{"context":{"action":"select"},"message":{"order":{}}}`,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validator.Validate(context.Background(), nil, []byte(tt.payload))
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSpec_LocalFile(t *testing.T) {
|
||||
tmpFile, err := os.CreateTemp("", "test-spec-*.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp file: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
if _, err := tmpFile.Write([]byte(testSpec)); err != nil {
|
||||
t.Fatalf("Failed to write temp file: %v", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
validator, _, err := New(context.Background(), &Config{Type: "file", Location: tmpFile.Name(), CacheTTL: 3600})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load local spec: %v", err)
|
||||
}
|
||||
|
||||
validator.specMutex.RLock()
|
||||
defer validator.specMutex.RUnlock()
|
||||
|
||||
if validator.spec == nil || validator.spec.doc == nil {
|
||||
t.Error("Spec not loaded from local file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheTTL_DefaultValue(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(testSpec))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
validator, _, err := New(context.Background(), &Config{Type: "url", Location: server.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create validator: %v", err)
|
||||
}
|
||||
|
||||
if validator.config.CacheTTL != 3600 {
|
||||
t.Errorf("Expected default CacheTTL 3600, got %d", validator.config.CacheTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_EdgeCases(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(testSpec))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
validator, _, err := New(context.Background(), &Config{Type: "url", Location: server.URL, CacheTTL: 3600})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create validator: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
payload string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty payload",
|
||||
payload: `{}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "null context",
|
||||
payload: `{"context":null,"message":{}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty string action",
|
||||
payload: `{"context":{"action":""},"message":{}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "action with whitespace",
|
||||
payload: `{"context":{"action":" search "},"message":{}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "case sensitive action",
|
||||
payload: `{"context":{"action":"Search"},"message":{}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validator.Validate(context.Background(), nil, []byte(tt.payload))
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
if len(substr) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(s) < len(substr) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user