diff --git a/pkg/model/model.go b/pkg/model/model.go index 5c7fb7e..dac898f 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -62,12 +62,16 @@ const ( ) var contextKeys = map[string]ContextKey{ + // snake_case keys (legacy beckn spec) "transaction_id": ContextKeyTxnID, "message_id": ContextKeyMsgID, "subscriber_id": ContextKeySubscriberID, "module_id": ContextKeyModuleID, "parent_id": ContextKeyParentID, "remote_id": ContextKeyRemoteID, + // camelCase aliases (new beckn spec — map to the same internal constants) + "transactionId": ContextKeyTxnID, + "messageId": ContextKeyMsgID, } // ParseContextKey converts a string into a valid ContextKey. diff --git a/pkg/plugin/implementation/reqpreprocessor/reqpreprocessor.go b/pkg/plugin/implementation/reqpreprocessor/reqpreprocessor.go index 23f1461..c99f30b 100644 --- a/pkg/plugin/implementation/reqpreprocessor/reqpreprocessor.go +++ b/pkg/plugin/implementation/reqpreprocessor/reqpreprocessor.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "strings" "github.com/beckn-one/beckn-onix/pkg/log" "github.com/beckn-one/beckn-onix/pkg/model" @@ -22,6 +23,34 @@ type Config struct { const contextKey = "context" +// firstNonNil returns the first non-nil value from the provided list. +// Used to resolve context fields that may appear under different key names +// (e.g. bap_id or bapId) depending on the beckn spec version in use. +func firstNonNil(values ...any) any { + for _, v := range values { + if v != nil { + return v + } + } + return nil +} + +// snakeToCamel converts a snake_case string to camelCase. +// For example: "transaction_id" -> "transactionId". +// Returns the input unchanged if it contains no underscores. +func snakeToCamel(s string) string { + parts := strings.Split(s, "_") + if len(parts) == 1 { + return s + } + for i := 1; i < len(parts); i++ { + if len(parts[i]) > 0 { + parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:] + } + } + return strings.Join(parts, "") +} + // NewPreProcessor returns a middleware that processes the incoming request, // extracts the context field from the body, and adds relevant values (like subscriber ID). func NewPreProcessor(cfg *Config) (func(http.Handler) http.Handler, error) { @@ -49,21 +78,24 @@ func NewPreProcessor(cfg *Config) (func(http.Handler) http.Handler, error) { return } + // Resolve subscriber ID — checks snake_case key first, falls back to camelCase. var subID any switch cfg.Role { case "bap": - subID = reqContext["bap_id"] + subID = firstNonNil(reqContext["bap_id"], reqContext["bapId"]) case "bpp": - subID = reqContext["bpp_id"] + subID = firstNonNil(reqContext["bpp_id"], reqContext["bppId"]) } + // Resolve caller ID — same dual-key pattern, opposite role. var callerID any switch cfg.Role { case "bap": - callerID = reqContext["bpp_id"] + callerID = firstNonNil(reqContext["bpp_id"], reqContext["bppId"]) case "bpp": - callerID = reqContext["bap_id"] + callerID = firstNonNil(reqContext["bap_id"], reqContext["bapId"]) } + if subID != nil { log.Debugf(ctx, "adding subscriberId to request:%s, %v", model.ContextKeySubscriberID, subID) ctx = context.WithValue(ctx, model.ContextKeySubscriberID, subID) @@ -78,10 +110,18 @@ func NewPreProcessor(cfg *Config) (func(http.Handler) http.Handler, error) { log.Debugf(ctx, "adding callerID to request:%s, %v", model.ContextKeyRemoteID, callerID) ctx = context.WithValue(ctx, model.ContextKeyRemoteID, callerID) } + + // Extract generic context keys (e.g. transaction_id, message_id). + // For each configured snake_case key, also try its camelCase equivalent + // so that a single config entry covers both beckn spec versions. for _, key := range cfg.ContextKeys { ctxKey, _ := model.ParseContextKey(key) if v, ok := reqContext[key]; ok { ctx = context.WithValue(ctx, ctxKey, v) + } else if camelKey := snakeToCamel(key); camelKey != key { + if v, ok := reqContext[camelKey]; ok { + ctx = context.WithValue(ctx, ctxKey, v) + } } } r.Body = io.NopCloser(bytes.NewBuffer(body)) diff --git a/pkg/plugin/implementation/reqpreprocessor/reqpreprocessor_test.go b/pkg/plugin/implementation/reqpreprocessor/reqpreprocessor_test.go index 1e9f0c1..a112a3a 100644 --- a/pkg/plugin/implementation/reqpreprocessor/reqpreprocessor_test.go +++ b/pkg/plugin/implementation/reqpreprocessor/reqpreprocessor_test.go @@ -235,6 +235,144 @@ func TestNewPreProcessorErrorCases(t *testing.T) { } } +// TestSnakeToCamel tests the snakeToCamel conversion helper. +func TestSnakeToCamel(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"transaction_id", "transactionId"}, + {"message_id", "messageId"}, + {"bap_id", "bapId"}, + {"bpp_id", "bppId"}, + {"bap_uri", "bapUri"}, + {"bpp_uri", "bppUri"}, + {"domain", "domain"}, // no underscore — unchanged + {"version", "version"}, // no underscore — unchanged + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := snakeToCamel(tt.input) + if got != tt.want { + t.Errorf("snakeToCamel(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +// TestCamelCaseSubscriberID tests that bapId / bppId are resolved when the payload +// uses camelCase context attribute names (new beckn spec). +func TestCamelCaseSubscriberID(t *testing.T) { + tests := []struct { + name string + role string + contextBody map[string]interface{} + wantSubID string + wantCaller string + }{ + { + name: "BAP role — camelCase bapId resolved as subscriber", + role: "bap", + contextBody: map[string]interface{}{ + "bapId": "bap.example.com", + "bppId": "bpp.example.com", + }, + wantSubID: "bap.example.com", + wantCaller: "bpp.example.com", + }, + { + name: "BPP role — camelCase bppId resolved as subscriber", + role: "bpp", + contextBody: map[string]interface{}{ + "bapId": "bap.example.com", + "bppId": "bpp.example.com", + }, + wantSubID: "bpp.example.com", + wantCaller: "bap.example.com", + }, + { + name: "snake_case still takes precedence over camelCase", + role: "bap", + contextBody: map[string]interface{}{ + "bap_id": "bap-snake.example.com", + "bapId": "bap-camel.example.com", + "bpp_id": "bpp-snake.example.com", + "bppId": "bpp-camel.example.com", + }, + wantSubID: "bap-snake.example.com", + wantCaller: "bpp-snake.example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{Role: tt.role} + middleware, err := NewPreProcessor(cfg) + if err != nil { + t.Fatalf("NewPreProcessor() error = %v", err) + } + + body, _ := json.Marshal(map[string]interface{}{"context": tt.contextBody}) + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + + var gotSubID, gotCaller interface{} + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotSubID = r.Context().Value(model.ContextKeySubscriberID) + gotCaller = r.Context().Value(model.ContextKeyRemoteID) + w.WriteHeader(http.StatusOK) + }) + + middleware(handler).ServeHTTP(httptest.NewRecorder(), req) + + if gotSubID != tt.wantSubID { + t.Errorf("subscriber ID: got %v, want %v", gotSubID, tt.wantSubID) + } + if gotCaller != tt.wantCaller { + t.Errorf("caller ID: got %v, want %v", gotCaller, tt.wantCaller) + } + }) + } +} + +// TestCamelCaseContextKeys tests that generic context keys (e.g. transaction_id) +// are resolved from their camelCase equivalents (transactionId) when the +// snake_case key is absent from the payload. +func TestCamelCaseContextKeys(t *testing.T) { + cfg := &Config{ + Role: "bap", + ContextKeys: []string{"transaction_id", "message_id"}, + } + middleware, err := NewPreProcessor(cfg) + if err != nil { + t.Fatalf("NewPreProcessor() error = %v", err) + } + + body, _ := json.Marshal(map[string]interface{}{ + "context": map[string]interface{}{ + "bapId": "bap.example.com", + "transactionId": "txn-abc", + "messageId": "msg-xyz", + }, + }) + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + + var gotTxnID, gotMsgID interface{} + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotTxnID = r.Context().Value(model.ContextKeyTxnID) + gotMsgID = r.Context().Value(model.ContextKeyMsgID) + w.WriteHeader(http.StatusOK) + }) + + middleware(handler).ServeHTTP(httptest.NewRecorder(), req) + + if gotTxnID != "txn-abc" { + t.Errorf("transaction_id: got %v, want txn-abc", gotTxnID) + } + if gotMsgID != "msg-xyz" { + t.Errorf("message_id: got %v, want msg-xyz", gotMsgID) + } +} + func TestNewPreProcessorAddsSubscriberIDToContext(t *testing.T) { cfg := &Config{Role: "bap"} middleware, err := NewPreProcessor(cfg) diff --git a/pkg/plugin/implementation/schemav2validator/extended_schema.go b/pkg/plugin/implementation/schemav2validator/extended_schema.go index 92c5ed0..6a189ca 100644 --- a/pkg/plugin/implementation/schemav2validator/extended_schema.go +++ b/pkg/plugin/implementation/schemav2validator/extended_schema.go @@ -239,6 +239,7 @@ func (c *schemaCache) loadSchemaFromPath(ctx context.Context, schemaPath string, loadCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() loader.Context = loadCtx + log.Debugf(ctx, "Fetching schema from URL: %s (timeout=%v)", schemaPath, timeout) doc, err = loader.LoadFromURI(u) } else { // Load from local file (file:// or path) @@ -246,6 +247,7 @@ func (c *schemaCache) loadSchemaFromPath(ctx context.Context, schemaPath string, if u != nil && u.Scheme == "file" { filePath = u.Path } + log.Debugf(ctx, "Loading schema from local file: %s", filePath) doc, err = loader.LoadFromFile(filePath) } @@ -254,9 +256,13 @@ func (c *schemaCache) loadSchemaFromPath(ctx context.Context, schemaPath string, return nil, fmt.Errorf("failed to load schema from %s: %w", schemaPath, err) } + log.Debugf(ctx, "Successfully loaded schema from: %s", schemaPath) + // Validate loaded schema (non-blocking, just log warnings) if err := doc.Validate(ctx); err != nil { - log.Debugf(ctx, "Schema validation warnings for %s: %v", schemaPath, err) + log.Warnf(ctx, "Schema validation warnings for %s (may indicate unresolved $ref): %v", schemaPath, err) + } else { + log.Debugf(ctx, "Schema self-validation passed for: %s", schemaPath) } c.set(urlHash, doc, ttl) @@ -315,9 +321,12 @@ func transformContextToSchemaURL(contextURL string) string { // findSchemaByType finds a schema in the document by @type value. func findSchemaByType(ctx context.Context, doc *openapi3.T, typeName string) (*openapi3.SchemaRef, error) { if doc.Components == nil || doc.Components.Schemas == nil { + log.Errorf(ctx, fmt.Errorf("no schemas in document"), "Schema lookup failed for @type: %s — document has no components.schemas section", typeName) return nil, fmt.Errorf("no schemas found in document") } + log.Debugf(ctx, "Looking up @type: %s in document with %d schema(s)", typeName, len(doc.Components.Schemas)) + // Try direct match by schema name if schema, exists := doc.Components.Schemas[typeName]; exists { log.Debugf(ctx, "Found schema by direct match: %s", typeName) @@ -337,6 +346,22 @@ func findSchemaByType(ctx context.Context, doc *openapi3.T, typeName string) (*o } } + // Log available schema names and x-jsonld.@type values to help diagnose the mismatch + available := make([]string, 0, len(doc.Components.Schemas)) + for name, schema := range doc.Components.Schemas { + entry := name + if schema.Value != nil { + if xJsonld, ok := schema.Value.Extensions["x-jsonld"].(map[string]interface{}); ok { + if atType, ok := xJsonld["@type"].(string); ok { + entry = fmt.Sprintf("%s (x-jsonld.@type=%s)", name, atType) + } + } + } + available = append(available, entry) + } + log.Errorf(ctx, fmt.Errorf("no schema found for @type: %s", typeName), + "Schema lookup failed — @type %q not matched by name or x-jsonld.@type. Available schemas: %v", typeName, available) + return nil, fmt.Errorf("no schema found for @type: %s", typeName) } @@ -373,9 +398,18 @@ func (c *schemaCache) validateReferencedObject( // Load schema with timeout (supports URL or local file) doc, err := c.loadSchemaFromPath(ctx, schemaPath, ttl, timeout) if err != nil { + log.Errorf(ctx, err, "Failed to load schema for @type: %s from URL: %s (derived from @context: %s)", + obj.Type, schemaPath, obj.Context) return fmt.Errorf("at %s: %w", obj.Path, err) } + // Log doc structure to help diagnose schema-not-found issues + if doc.Components == nil || doc.Components.Schemas == nil { + log.Warnf(ctx, "Schema doc loaded from %s has no components.schemas — @type %s cannot be resolved", schemaPath, obj.Type) + } else { + log.Debugf(ctx, "Schema doc loaded from %s contains %d schema(s) in components", schemaPath, len(doc.Components.Schemas)) + } + // Find schema by @type schema, err := findSchemaByType(ctx, doc, obj.Type) if err != nil { diff --git a/pkg/plugin/implementation/schemav2validator/schemav2validator.go b/pkg/plugin/implementation/schemav2validator/schemav2validator.go index f7050b7..9551561 100644 --- a/pkg/plugin/implementation/schemav2validator/schemav2validator.go +++ b/pkg/plugin/implementation/schemav2validator/schemav2validator.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "io" + "net/http" "net/url" "strings" "sync" @@ -155,6 +157,27 @@ func (v *schemav2Validator) initialise(ctx context.Context) error { return v.loadSpec(ctx) } +// readFromURI fetches a URL and returns its raw bytes. +func readFromURI(ctx context.Context, u *url.URL) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("failed to build request for %s: %w", u, err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch %s: %w", u, err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read body from %s: %w", u, err) + } + + log.Debugf(ctx, "External ref resolved: %s (%d bytes, HTTP %d)", u, len(data), resp.StatusCode) + return data, nil +} + // loadSpec loads the OpenAPI spec from URL or local path. func (v *schemav2Validator) loadSpec(ctx context.Context) error { loader := openapi3.NewLoader() @@ -162,6 +185,30 @@ func (v *schemav2Validator) loadSpec(ctx context.Context) error { // Allow external references loader.IsExternalRefsAllowed = true + // Log every URI kin-openapi resolves so we can trace the full $ref chain. + // + // Exception: json-schema.org meta-schema URLs are intercepted and short- + // circuited with an empty schema object. kin-openapi follows the $schema + // dialect URI declared in each Beckn schema file, which leads it deep into + // the JSON Schema 2020-12 meta-schema hierarchy. Those meta-schemas use + // boolean schemas (e.g. "additionalProperties": false, "items": false) that + // are valid JSON Schema but cannot be parsed by kin-openapi's OpenAPI Schema + // Object model. Since the meta-schemas carry no Beckn-specific content and + // are not used for validation, returning {} is safe and correct. + loader.ReadFromURIFunc = func(loader *openapi3.Loader, u *url.URL) ([]byte, error) { + if u.Host == "json-schema.org" { + log.Debugf(ctx, "Skipping json-schema.org meta-schema (not an OpenAPI schema): %s", u) + return []byte(`{}`), nil + } + log.Debugf(ctx, "Resolving external $ref: %s", u) + data, err := readFromURI(ctx, u) + if err != nil { + log.Errorf(ctx, err, "Failed to resolve external $ref: %s", u) + return nil, err + } + return data, nil + } + var doc *openapi3.T var err error @@ -185,9 +232,10 @@ func (v *schemav2Validator) loadSpec(ctx context.Context) error { return fmt.Errorf("failed to load OpenAPI document: %v", err) } - // Validate spec (skip strict validation to allow JSON Schema keywords) + // Validate spec — this also triggers resolution of all $refs including external ones. + // Log the error but treat as non-fatal to allow JSON Schema keywords not in OpenAPI 3.0. if err := doc.Validate(ctx); err != nil { - log.Debugf(ctx, "Spec validation warnings (non-fatal): %v", err) + log.Errorf(ctx, err, "Spec validation error (external refs may not have resolved): %v", err) } else { log.Debugf(ctx, "Spec validation passed") } @@ -195,6 +243,10 @@ func (v *schemav2Validator) loadSpec(ctx context.Context) error { // Build action→schema index for O(1) lookup actionSchemas := v.buildActionIndex(ctx, doc) + if len(actionSchemas) == 0 { + log.Errorf(ctx, fmt.Errorf("no actions indexed"), "No actions indexed from spec — external $refs may not have resolved. Check that IsExternalRefsAllowed=true and the referenced URLs are reachable and return valid YAML/JSON") + } + v.specMutex.Lock() v.spec = &cachedSpec{ doc: doc,