Merge pull request #549 from Beckn-One/543-schema2validator
543 - Add schemav2validator plugin for loading spec and validating incoming requests with full $ref resolution
This commit is contained in:
@@ -18,6 +18,7 @@ plugins=(
|
|||||||
"reqpreprocessor"
|
"reqpreprocessor"
|
||||||
"router"
|
"router"
|
||||||
"schemavalidator"
|
"schemavalidator"
|
||||||
|
"schemav2validator"
|
||||||
"signer"
|
"signer"
|
||||||
"signvalidator"
|
"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
|
||||||
|
}
|
||||||
356
pkg/plugin/implementation/schemav2validator/schemav2validator.go
Normal file
356
pkg/plugin/implementation/schemav2validator/schemav2validator.go
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
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
|
||||||
|
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
|
||||||
|
var schema *openapi3.SchemaRef
|
||||||
|
var matchedPath string
|
||||||
|
|
||||||
|
// Search all spec paths for matching action in schema
|
||||||
|
for path, item := range spec.doc.Paths.Map() {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Check all HTTP methods for this path
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// Check if schema has action constraint matching our action
|
||||||
|
if v.schemaMatchesAction(content.Schema.Value, action) {
|
||||||
|
schema = content.Schema
|
||||||
|
matchedPath = path
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if schema != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if schema == nil || schema.Value == nil {
|
||||||
|
return model.NewBadReqErr(fmt.Errorf("unsupported action: %s", action))
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf(ctx, "Validating action: %s, matched path: %s", action, matchedPath)
|
||||||
|
|
||||||
|
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.MultiErrors(),
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
v.specMutex.Lock()
|
||||||
|
v.spec = &cachedSpec{
|
||||||
|
doc: doc,
|
||||||
|
loadedAt: time.Now(),
|
||||||
|
}
|
||||||
|
v.specMutex.Unlock()
|
||||||
|
|
||||||
|
log.Debugf(ctx, "Loaded OpenAPI spec from %s: %s", v.config.Type, v.config.Location)
|
||||||
|
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 {
|
||||||
|
// If there's an origin error, recursively extract from it
|
||||||
|
if schemaErr.Origin != nil {
|
||||||
|
v.extractSchemaErrors(schemaErr.Origin, schemaErrors)
|
||||||
|
} else {
|
||||||
|
// Leaf error - extract the actual validation failure
|
||||||
|
pathParts := schemaErr.JSONPointer()
|
||||||
|
path := strings.Join(pathParts, "/")
|
||||||
|
if path == "" {
|
||||||
|
path = schemaErr.SchemaField
|
||||||
|
}
|
||||||
|
*schemaErrors = append(*schemaErrors, model.Error{
|
||||||
|
Paths: path,
|
||||||
|
Message: schemaErr.Reason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} 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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// schemaMatchesAction checks if a schema has an action constraint matching the given action.
|
||||||
|
func (v *schemav2Validator) schemaMatchesAction(schema *openapi3.Schema, action string) bool {
|
||||||
|
// Check direct properties
|
||||||
|
if ctxProp := schema.Properties["context"]; ctxProp != nil && ctxProp.Value != nil {
|
||||||
|
if v.checkActionEnum(ctxProp.Value, action) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 v.checkActionEnum(ctxProp.Value, action) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkActionEnum checks if a context schema has action enum or const matching the given action.
|
||||||
|
func (v *schemav2Validator) checkActionEnum(contextSchema *openapi3.Schema, action string) bool {
|
||||||
|
// Check direct action property
|
||||||
|
if actionProp := contextSchema.Properties["action"]; actionProp != nil && actionProp.Value != nil {
|
||||||
|
// Check const field (stored in Extensions by kin-openapi)
|
||||||
|
if constVal, ok := actionProp.Value.Extensions["const"]; ok {
|
||||||
|
if constVal == action {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check enum field
|
||||||
|
if len(actionProp.Value.Enum) > 0 {
|
||||||
|
for _, e := range actionProp.Value.Enum {
|
||||||
|
if e == action {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check allOf in context
|
||||||
|
for _, allOfSchema := range contextSchema.AllOf {
|
||||||
|
if allOfSchema.Value != nil {
|
||||||
|
if actionProp := allOfSchema.Value.Properties["action"]; actionProp != nil && actionProp.Value != nil {
|
||||||
|
// Check const field (stored in Extensions by kin-openapi)
|
||||||
|
if constVal, ok := actionProp.Value.Extensions["const"]; ok {
|
||||||
|
if constVal == action {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check enum field
|
||||||
|
if len(actionProp.Value.Enum) > 0 {
|
||||||
|
for _, e := range actionProp.Value.Enum {
|
||||||
|
if e == action {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Recursively check nested allOf
|
||||||
|
if v.checkActionEnum(allOfSchema.Value, action) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -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