Issue 543 - feat: add new validator plugin
This commit is contained in:
42
pkg/plugin/implementation/schemav2validator/cmd/plugin.go
Normal file
42
pkg/plugin/implementation/schemav2validator/cmd/plugin.go
Normal file
@@ -0,0 +1,42 @@
|
||||
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")
|
||||
}
|
||||
|
||||
url, ok := config["url"]
|
||||
if !ok || url == "" {
|
||||
return nil, nil, errors.New("url not configured")
|
||||
}
|
||||
|
||||
cacheTTL := 3600
|
||||
if ttlStr, ok := config["cacheTTL"]; ok {
|
||||
if ttl, err := strconv.Atoi(ttlStr); err == nil && ttl > 0 {
|
||||
cacheTTL = ttl
|
||||
}
|
||||
}
|
||||
|
||||
cfg := &schemav2validator.Config{
|
||||
URL: url,
|
||||
CacheTTL: cacheTTL,
|
||||
}
|
||||
|
||||
return schemav2validator.New(ctx, cfg)
|
||||
}
|
||||
|
||||
// Provider is the exported plugin provider.
|
||||
var Provider schemav2ValidatorProvider
|
||||
346
pkg/plugin/implementation/schemav2validator/schemav2validator.go
Normal file
346
pkg/plugin/implementation/schemav2validator/schemav2validator.go
Normal file
@@ -0,0 +1,346 @@
|
||||
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 {
|
||||
URL string
|
||||
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.URL == "" {
|
||||
return nil, nil, fmt.Errorf("config URL cannot be empty")
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
urlOrPath := v.config.URL
|
||||
|
||||
if strings.HasPrefix(urlOrPath, "http://") || strings.HasPrefix(urlOrPath, "https://") {
|
||||
u, parseErr := url.Parse(urlOrPath)
|
||||
if parseErr != nil {
|
||||
return fmt.Errorf("failed to parse URL: %v", parseErr)
|
||||
}
|
||||
doc, err = loader.LoadFromURI(u)
|
||||
} else {
|
||||
doc, err = loader.LoadFromFile(urlOrPath)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, err, "Invalid URL or unreachable: %s", urlOrPath)
|
||||
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", urlOrPath)
|
||||
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", v.config.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user