fix(reqpreprocessor): support camelCase context attributes for beckn spec migration

- Add transactionId and messageId as camelCase aliases in model.go contextKeys map,
  pointing to the same ContextKeyTxnID and ContextKeyMsgID constants. This allows
  adapter configs to reference either form without failing startup validation.

- In reqpreprocessor, add firstNonNil() helper for subscriber/caller ID lookups
  so bap_id/bapId and bpp_id/bppId are both resolved correctly regardless of
  which beckn spec version the payload uses. snake_case takes precedence when both
  are present.

- Add snakeToCamel() helper used in the context key loop so a single config entry
  (e.g. transaction_id) automatically also checks the camelCase form (transactionId)
  without requiring any config file changes.

- Add TestSnakeToCamel, TestCamelCaseSubscriberID, TestCamelCaseContextKeys to
  cover all new code paths.

Fixes #637

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mayuresh
2026-03-25 15:40:36 +05:30
parent 6d4f45a632
commit 1be757a165
5 changed files with 275 additions and 7 deletions

View File

@@ -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.

View File

@@ -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))

View File

@@ -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)

View File

@@ -239,6 +239,7 @@ func (c *schemaCache) loadSchemaFromPath(ctx context.Context, schemaPath string,
loadCtx, cancel := context.WithTimeout(ctx, timeout) loadCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
loader.Context = loadCtx loader.Context = loadCtx
log.Debugf(ctx, "Fetching schema from URL: %s (timeout=%v)", schemaPath, timeout)
doc, err = loader.LoadFromURI(u) doc, err = loader.LoadFromURI(u)
} else { } else {
// Load from local file (file:// or path) // 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" { if u != nil && u.Scheme == "file" {
filePath = u.Path filePath = u.Path
} }
log.Debugf(ctx, "Loading schema from local file: %s", filePath)
doc, err = loader.LoadFromFile(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) 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) // Validate loaded schema (non-blocking, just log warnings)
if err := doc.Validate(ctx); err != nil { 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) c.set(urlHash, doc, ttl)
@@ -315,9 +321,12 @@ func transformContextToSchemaURL(contextURL string) string {
// findSchemaByType finds a schema in the document by @type value. // findSchemaByType finds a schema in the document by @type value.
func findSchemaByType(ctx context.Context, doc *openapi3.T, typeName string) (*openapi3.SchemaRef, error) { func findSchemaByType(ctx context.Context, doc *openapi3.T, typeName string) (*openapi3.SchemaRef, error) {
if doc.Components == nil || doc.Components.Schemas == nil { 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") 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 // Try direct match by schema name
if schema, exists := doc.Components.Schemas[typeName]; exists { if schema, exists := doc.Components.Schemas[typeName]; exists {
log.Debugf(ctx, "Found schema by direct match: %s", typeName) 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) 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) // Load schema with timeout (supports URL or local file)
doc, err := c.loadSchemaFromPath(ctx, schemaPath, ttl, timeout) doc, err := c.loadSchemaFromPath(ctx, schemaPath, ttl, timeout)
if err != nil { 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) 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 // Find schema by @type
schema, err := findSchemaByType(ctx, doc, obj.Type) schema, err := findSchemaByType(ctx, doc, obj.Type)
if err != nil { if err != nil {

View File

@@ -4,6 +4,8 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http"
"net/url" "net/url"
"strings" "strings"
"sync" "sync"
@@ -155,6 +157,27 @@ func (v *schemav2Validator) initialise(ctx context.Context) error {
return v.loadSpec(ctx) 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. // loadSpec loads the OpenAPI spec from URL or local path.
func (v *schemav2Validator) loadSpec(ctx context.Context) error { func (v *schemav2Validator) loadSpec(ctx context.Context) error {
loader := openapi3.NewLoader() loader := openapi3.NewLoader()
@@ -162,6 +185,30 @@ func (v *schemav2Validator) loadSpec(ctx context.Context) error {
// Allow external references // Allow external references
loader.IsExternalRefsAllowed = true 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 doc *openapi3.T
var err error 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) 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 { 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 { } else {
log.Debugf(ctx, "Spec validation passed") 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 // Build action→schema index for O(1) lookup
actionSchemas := v.buildActionIndex(ctx, doc) 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.specMutex.Lock()
v.spec = &cachedSpec{ v.spec = &cachedSpec{
doc: doc, doc: doc,