diff --git a/install/build-plugins.sh b/install/build-plugins.sh index bcf1275..1799f00 100755 --- a/install/build-plugins.sh +++ b/install/build-plugins.sh @@ -18,6 +18,7 @@ plugins=( "reqpreprocessor" "router" "schemavalidator" + "schemav2validator" "signer" "signvalidator" ) diff --git a/pkg/plugin/implementation/schemav2validator/README.md b/pkg/plugin/implementation/schemav2validator/README.md new file mode 100644 index 0000000..b76ad99 --- /dev/null +++ b/pkg/plugin/implementation/schemav2validator/README.md @@ -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: "` | +| Invalid URL | `"Invalid URL or unreachable: "` | +| Schema validation fails | Returns detailed field-level errors | + diff --git a/pkg/plugin/implementation/schemav2validator/cmd/plugin.go b/pkg/plugin/implementation/schemav2validator/cmd/plugin.go new file mode 100644 index 0000000..9d147fe --- /dev/null +++ b/pkg/plugin/implementation/schemav2validator/cmd/plugin.go @@ -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 diff --git a/pkg/plugin/implementation/schemav2validator/cmd/plugin_test.go b/pkg/plugin/implementation/schemav2validator/cmd/plugin_test.go new file mode 100644 index 0000000..42798dc --- /dev/null +++ b/pkg/plugin/implementation/schemav2validator/cmd/plugin_test.go @@ -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 +} diff --git a/pkg/plugin/implementation/schemav2validator/schemav2validator.go b/pkg/plugin/implementation/schemav2validator/schemav2validator.go new file mode 100644 index 0000000..bebbcca --- /dev/null +++ b/pkg/plugin/implementation/schemav2validator/schemav2validator.go @@ -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 +} diff --git a/pkg/plugin/implementation/schemav2validator/schemav2validator_test.go b/pkg/plugin/implementation/schemav2validator/schemav2validator_test.go new file mode 100644 index 0000000..8852fff --- /dev/null +++ b/pkg/plugin/implementation/schemav2validator/schemav2validator_test.go @@ -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 +}