Merge pull request #640 from beckn/new-context
fix(reqpreprocessor): support camelCase context attributes (bapId, bppId, transactionId, messageId)
This commit is contained in:
@@ -62,12 +62,16 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var contextKeys = map[string]ContextKey{
|
var contextKeys = map[string]ContextKey{
|
||||||
|
// snake_case keys (legacy beckn spec)
|
||||||
"transaction_id": ContextKeyTxnID,
|
"transaction_id": ContextKeyTxnID,
|
||||||
"message_id": ContextKeyMsgID,
|
"message_id": ContextKeyMsgID,
|
||||||
"subscriber_id": ContextKeySubscriberID,
|
"subscriber_id": ContextKeySubscriberID,
|
||||||
"module_id": ContextKeyModuleID,
|
"module_id": ContextKeyModuleID,
|
||||||
"parent_id": ContextKeyParentID,
|
"parent_id": ContextKeyParentID,
|
||||||
"remote_id": ContextKeyRemoteID,
|
"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.
|
// ParseContextKey converts a string into a valid ContextKey.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/beckn-one/beckn-onix/pkg/log"
|
"github.com/beckn-one/beckn-onix/pkg/log"
|
||||||
"github.com/beckn-one/beckn-onix/pkg/model"
|
"github.com/beckn-one/beckn-onix/pkg/model"
|
||||||
@@ -22,6 +23,34 @@ type Config struct {
|
|||||||
|
|
||||||
const contextKey = "context"
|
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,
|
// NewPreProcessor returns a middleware that processes the incoming request,
|
||||||
// extracts the context field from the body, and adds relevant values (like subscriber ID).
|
// extracts the context field from the body, and adds relevant values (like subscriber ID).
|
||||||
func NewPreProcessor(cfg *Config) (func(http.Handler) http.Handler, error) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve subscriber ID — checks snake_case key first, falls back to camelCase.
|
||||||
var subID any
|
var subID any
|
||||||
switch cfg.Role {
|
switch cfg.Role {
|
||||||
case "bap":
|
case "bap":
|
||||||
subID = reqContext["bap_id"]
|
subID = firstNonNil(reqContext["bap_id"], reqContext["bapId"])
|
||||||
case "bpp":
|
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
|
var callerID any
|
||||||
switch cfg.Role {
|
switch cfg.Role {
|
||||||
case "bap":
|
case "bap":
|
||||||
callerID = reqContext["bpp_id"]
|
callerID = firstNonNil(reqContext["bpp_id"], reqContext["bppId"])
|
||||||
case "bpp":
|
case "bpp":
|
||||||
callerID = reqContext["bap_id"]
|
callerID = firstNonNil(reqContext["bap_id"], reqContext["bapId"])
|
||||||
}
|
}
|
||||||
|
|
||||||
if subID != nil {
|
if subID != nil {
|
||||||
log.Debugf(ctx, "adding subscriberId to request:%s, %v", model.ContextKeySubscriberID, subID)
|
log.Debugf(ctx, "adding subscriberId to request:%s, %v", model.ContextKeySubscriberID, subID)
|
||||||
ctx = context.WithValue(ctx, 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)
|
log.Debugf(ctx, "adding callerID to request:%s, %v", model.ContextKeyRemoteID, callerID)
|
||||||
ctx = context.WithValue(ctx, 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 {
|
for _, key := range cfg.ContextKeys {
|
||||||
ctxKey, _ := model.ParseContextKey(key)
|
ctxKey, _ := model.ParseContextKey(key)
|
||||||
if v, ok := reqContext[key]; ok {
|
if v, ok := reqContext[key]; ok {
|
||||||
ctx = context.WithValue(ctx, ctxKey, v)
|
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))
|
r.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||||
|
|||||||
@@ -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) {
|
func TestNewPreProcessorAddsSubscriberIDToContext(t *testing.T) {
|
||||||
cfg := &Config{Role: "bap"}
|
cfg := &Config{Role: "bap"}
|
||||||
middleware, err := NewPreProcessor(cfg)
|
middleware, err := NewPreProcessor(cfg)
|
||||||
|
|||||||
Reference in New Issue
Block a user