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)