From 72593d2ab6e8b5cf0f5333e800fca8f593584b1b Mon Sep 17 00:00:00 2001 From: Mayuresh Date: Wed, 25 Mar 2026 15:41:16 +0530 Subject: [PATCH] fix(router): support camelCase context attributes (bppUri, bapUri) for beckn spec migration Replace fixed JSON struct tags for bpp_uri and bap_uri with a map-based parse of the context object. A new getContextString() helper checks the snake_case key first and falls back to the camelCase key, so routing works transparently for both the legacy beckn spec and the new camelCase convention. Also adds a nil-context guard so a missing context field returns a clear error instead of a panic. Test coverage: - Two new cases in TestRouteSuccess for bppUri and bapUri camelCase payloads - TestGetContextString: 5 table-driven cases covering snake, camel, precedence, missing, and empty-snake-fallthrough scenarios - TestRouteNilContext: confirms clear error on missing context field Fixes #636 Co-Authored-By: Claude Sonnet 4.6 --- pkg/plugin/implementation/router/router.go | 35 ++++++-- .../implementation/router/router_test.go | 85 +++++++++++++++++++ 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/pkg/plugin/implementation/router/router.go b/pkg/plugin/implementation/router/router.go index a42a4af..0e73620 100644 --- a/pkg/plugin/implementation/router/router.go +++ b/pkg/plugin/implementation/router/router.go @@ -199,21 +199,46 @@ func validateRules(rules []routingRule) error { return nil } +// getContextString returns the value for a context field, checking the snake_case +// key first and falling back to the camelCase key. This supports both the legacy +// beckn spec (snake_case) and the new camelCase convention transparently. +func getContextString(ctx map[string]interface{}, snakeKey, camelKey string) string { + if v, ok := ctx[snakeKey].(string); ok && v != "" { + return v + } + if v, ok := ctx[camelKey].(string); ok && v != "" { + return v + } + return "" +} + // Route determines the routing destination based on the request context. func (r *Router) Route(ctx context.Context, url *url.URL, body []byte) (*model.Route, error) { - // Parse the body to extract domain and version + // Parse domain and version via typed struct — unchanged from original. var requestBody struct { Context struct { Domain string `json:"domain"` Version string `json:"version"` - BPPURI string `json:"bpp_uri,omitempty"` - BAPURI string `json:"bap_uri,omitempty"` } `json:"context"` } if err := json.Unmarshal(body, &requestBody); err != nil { return nil, fmt.Errorf("error parsing request body: %w", err) } + // Parse context as a map solely to resolve URI fields that have both + // snake_case (bpp_uri, bap_uri) and camelCase (bppUri, bapUri) variants. + var uriBody struct { + Context map[string]interface{} `json:"context"` + } + if err := json.Unmarshal(body, &uriBody); err != nil { + return nil, fmt.Errorf("error parsing request body: %w", err) + } + if uriBody.Context == nil { + return nil, fmt.Errorf("context field not found or invalid in request body") + } + bppURI := getContextString(uriBody.Context, "bpp_uri", "bppUri") + bapURI := getContextString(uriBody.Context, "bap_uri", "bapUri") + // Extract the endpoint from the URL endpoint := path.Base(url.Path) @@ -251,9 +276,9 @@ func (r *Router) Route(ctx context.Context, url *url.URL, body []byte) (*model.R // Handle BPP/BAP routing with request URIs switch route.TargetType { case targetTypeBPP: - return handleProtocolMapping(route, requestBody.Context.BPPURI, endpoint) + return handleProtocolMapping(route, bppURI, endpoint) case targetTypeBAP: - return handleProtocolMapping(route, requestBody.Context.BAPURI, endpoint) + return handleProtocolMapping(route, bapURI, endpoint) } return route, nil } diff --git a/pkg/plugin/implementation/router/router_test.go b/pkg/plugin/implementation/router/router_test.go index 8642e61..66ecd51 100644 --- a/pkg/plugin/implementation/router/router_test.go +++ b/pkg/plugin/implementation/router/router_test.go @@ -470,6 +470,19 @@ func TestRouteSuccess(t *testing.T) { url: "https://example.com/v1/ondc/on_select", body: `{"context": {"domain": "ONDC:TRV10", "version": "1.1.0", "bpp_uri": "https://bpp1.example.com"}}`, }, + // camelCase variants (beckn spec camelCase migration) + { + name: "camelCase: bppUri in context is resolved for bpp routing", + configFile: "bap_caller.yaml", + url: "https://example.com/v1/ondc/select", + body: `{"context": {"domain": "ONDC:TRV10", "version": "1.1.0", "bppUri": "https://bpp1.example.com"}}`, + }, + { + name: "camelCase: bapUri in context is resolved for bap routing", + configFile: "bpp_caller.yaml", + url: "https://example.com/v1/ondc/on_select", + body: `{"context": {"domain": "ONDC:TRV10", "version": "1.1.0", "bapUri": "https://bap1.example.com"}}`, + }, } for _, tt := range tests { @@ -762,6 +775,78 @@ func TestV2ConflictingRules(t *testing.T) { } } +// TestGetContextString tests the dual-key lookup helper used to support both +// snake_case (legacy) and camelCase (new beckn spec) context attribute names. +func TestGetContextString(t *testing.T) { + tests := []struct { + name string + ctx map[string]interface{} + snakeKey string + camelKey string + want string + }{ + { + name: "snake_case key present", + ctx: map[string]interface{}{"bpp_uri": "https://bpp.example.com"}, + snakeKey: "bpp_uri", + camelKey: "bppUri", + want: "https://bpp.example.com", + }, + { + name: "camelCase key present", + ctx: map[string]interface{}{"bppUri": "https://bpp.example.com"}, + snakeKey: "bpp_uri", + camelKey: "bppUri", + want: "https://bpp.example.com", + }, + { + name: "snake_case takes precedence when both present", + ctx: map[string]interface{}{"bpp_uri": "https://snake.example.com", "bppUri": "https://camel.example.com"}, + snakeKey: "bpp_uri", + camelKey: "bppUri", + want: "https://snake.example.com", + }, + { + name: "neither key present returns empty string", + ctx: map[string]interface{}{"domain": "ONDC:TRV10"}, + snakeKey: "bpp_uri", + camelKey: "bppUri", + want: "", + }, + { + name: "empty snake_case value falls through to camelCase", + ctx: map[string]interface{}{"bpp_uri": "", "bppUri": "https://bpp.example.com"}, + snakeKey: "bpp_uri", + camelKey: "bppUri", + want: "https://bpp.example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getContextString(tt.ctx, tt.snakeKey, tt.camelKey) + if got != tt.want { + t.Errorf("getContextString(%v, %q, %q) = %q, want %q", tt.ctx, tt.snakeKey, tt.camelKey, got, tt.want) + } + }) + } +} + +// TestRouteNilContext tests that Route returns a clear error when the context +// field is absent from the request body. +func TestRouteNilContext(t *testing.T) { + ctx := context.Background() + router, _, rulesFilePath := setupRouter(t, "bap_caller.yaml") + defer os.RemoveAll(filepath.Dir(rulesFilePath)) + + parsedURL, _ := url.Parse("https://example.com/v1/ondc/select") + _, err := router.Route(ctx, parsedURL, []byte(`{"message": {}}`)) + + if err == nil || !strings.Contains(err.Error(), "context field not found or invalid") { + t.Errorf("Route() with missing context = %v, want error containing 'context field not found or invalid'", err) + } +} + // TestV1DomainRequired tests that domain is required for v1 configs func TestV1DomainRequired(t *testing.T) { router := &Router{