update and merged with latest master

This commit is contained in:
Manendra Pal Singh
2025-12-15 10:09:39 +05:30
25 changed files with 1009 additions and 107 deletions

View File

@@ -0,0 +1,18 @@
package definition
import (
"context"
"net/http"
)
// TransportWrapper is a plugin that wraps an http.RoundTripper,
// allowing modification of outbound requests (like adding auth).
type TransportWrapper interface {
// Wrap takes a base transport and returns a new transport that wraps it.
Wrap(base http.RoundTripper) http.RoundTripper
}
// TransportWrapperProvider defines the factory for a TransportWrapper.
type TransportWrapperProvider interface {
New(ctx context.Context, config map[string]any) (TransportWrapper, func(), error)
}

View File

@@ -0,0 +1,23 @@
package main
import (
"context"
"net/http"
"github.com/beckn-one/beckn-onix/pkg/plugin/implementation/reqmapper"
)
type provider struct{}
func (p provider) New(ctx context.Context, c map[string]string) (func(http.Handler) http.Handler, error) {
config := &reqmapper.Config{}
if role, ok := c["role"]; ok {
config.Role = role
}
if mappingsFile, ok := c["mappingsFile"]; ok {
config.MappingsFile = mappingsFile
}
return reqmapper.NewReqMapper(config)
}
var Provider = provider{}

View File

@@ -0,0 +1,84 @@
package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestProviderNewSuccess(t *testing.T) {
p := provider{}
middleware, err := p.New(context.Background(), map[string]string{"role": "bap"})
if err != nil {
t.Fatalf("provider.New returned unexpected error: %v", err)
}
if middleware == nil {
t.Fatalf("provider.New returned nil middleware")
}
payload := map[string]interface{}{
"context": map[string]interface{}{
"action": "search",
"domain": "retail",
"version": "1.1.0",
"bap_id": "bap.example",
"bap_uri": "https://bap.example/api",
"transaction_id": "txn-1",
"message_id": "msg-1",
"timestamp": "2023-01-01T10:00:00Z",
},
"message": map[string]interface{}{
"intent": map[string]interface{}{
"fulfillment": map[string]interface{}{
"start": map[string]interface{}{
"location": map[string]interface{}{"gps": "0,0"},
},
"end": map[string]interface{}{
"location": map[string]interface{}{"gps": "1,1"},
},
},
},
},
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("failed to marshal payload: %v", err)
}
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
})
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
rec := httptest.NewRecorder()
middleware(next).ServeHTTP(rec, req)
if !called {
t.Fatalf("expected downstream handler to be invoked")
}
if rec.Code != http.StatusNoContent {
t.Fatalf("unexpected response code: got %d want %d", rec.Code, http.StatusNoContent)
}
}
func TestProviderNewMissingRole(t *testing.T) {
p := provider{}
if _, err := p.New(context.Background(), map[string]string{}); err == nil {
t.Fatalf("expected error when role is missing")
}
}
func TestProviderNewInvalidRole(t *testing.T) {
p := provider{}
_, err := p.New(context.Background(), map[string]string{"role": "invalid"})
if err == nil {
t.Fatalf("expected error for invalid role")
}
}

View File

@@ -0,0 +1,287 @@
package reqmapper
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"sync"
"github.com/beckn-one/beckn-onix/pkg/log"
"github.com/jsonata-go/jsonata"
"gopkg.in/yaml.v3"
)
// Config represents the configuration for the request mapper middleware.
type Config struct {
Role string `yaml:"role"` // "bap" or "bpp"
MappingsFile string `yaml:"mappingsFile"` // required path to mappings YAML
}
// MappingEngine handles JSONata-based transformations
type MappingEngine struct {
config *Config
jsonataInstance jsonata.JSONataInstance
bapMaps map[string]jsonata.Expression
bppMaps map[string]jsonata.Expression
mappings map[string]builtinMapping
mappingSource string
mutex sync.RWMutex
initialized bool
}
type builtinMapping struct {
BAP string `yaml:"bapMappings"`
BPP string `yaml:"bppMappings"`
}
type mappingFile struct {
Mappings map[string]builtinMapping `yaml:"mappings"`
}
var (
engineInstance *MappingEngine
engineOnce sync.Once
)
// NewReqMapper returns a middleware that maps requests using JSONata expressions
func NewReqMapper(cfg *Config) (func(http.Handler) http.Handler, error) {
if err := validateConfig(cfg); err != nil {
return nil, err
}
// Initialize the mapping engine
engine, err := initMappingEngine(cfg)
if err != nil {
return nil, fmt.Errorf("failed to initialize mapping engine: %w", err)
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
var req map[string]interface{}
ctx := r.Context()
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Failed to decode request body", http.StatusBadRequest)
return
}
reqContext, ok := req["context"].(map[string]interface{})
if !ok {
http.Error(w, "context field not found or invalid", http.StatusBadRequest)
return
}
action, ok := reqContext["action"].(string)
if !ok {
http.Error(w, "action field not found or invalid", http.StatusBadRequest)
return
}
// Apply transformation
mappedBody, err := engine.Transform(ctx, action, req, cfg.Role)
if err != nil {
log.Errorf(ctx, err, "Transformation failed for action %s", action)
// Fall back to original body on error
mappedBody = body
}
r.Body = io.NopCloser(bytes.NewBuffer(mappedBody))
r.ContentLength = int64(len(mappedBody))
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}, nil
}
// initMappingEngine initializes or returns existing mapping engine
func initMappingEngine(cfg *Config) (*MappingEngine, error) {
var initErr error
engineOnce.Do(func() {
engineInstance = &MappingEngine{
config: cfg,
bapMaps: make(map[string]jsonata.Expression),
bppMaps: make(map[string]jsonata.Expression),
}
instance, err := jsonata.OpenLatest()
if err != nil {
initErr = fmt.Errorf("failed to initialize jsonata: %w", err)
return
}
engineInstance.jsonataInstance = instance
if err := engineInstance.loadBuiltinMappings(); err != nil {
initErr = err
return
}
engineInstance.initialized = true
})
if initErr != nil {
return nil, initErr
}
if !engineInstance.initialized {
return nil, errors.New("mapping engine failed to initialize")
}
return engineInstance, nil
}
func (e *MappingEngine) loadMappingsFromConfig() (map[string]builtinMapping, string, error) {
if e.config == nil || e.config.MappingsFile == "" {
return nil, "", errors.New("mappingsFile must be provided in config")
}
data, err := os.ReadFile(e.config.MappingsFile)
if err != nil {
return nil, "", fmt.Errorf("failed to read mappings file %s: %w", e.config.MappingsFile, err)
}
source := e.config.MappingsFile
var parsed mappingFile
if err := yaml.Unmarshal(data, &parsed); err != nil {
return nil, "", fmt.Errorf("failed to parse mappings from %s: %w", source, err)
}
if len(parsed.Mappings) == 0 {
return nil, "", fmt.Errorf("no mappings found in %s", source)
}
return parsed.Mappings, source, nil
}
// loadBuiltinMappings compiles JSONata expressions for every action/direction pair from the configured mappings file.
func (e *MappingEngine) loadBuiltinMappings() error {
mappings, source, err := e.loadMappingsFromConfig()
if err != nil {
return err
}
e.bapMaps = make(map[string]jsonata.Expression, len(mappings))
e.bppMaps = make(map[string]jsonata.Expression, len(mappings))
for action, mapping := range mappings {
bapExpr, err := e.jsonataInstance.Compile(mapping.BAP, false)
if err != nil {
return fmt.Errorf("failed to compile BAP mapping for action %s: %w", action, err)
}
bppExpr, err := e.jsonataInstance.Compile(mapping.BPP, false)
if err != nil {
return fmt.Errorf("failed to compile BPP mapping for action %s: %w", action, err)
}
e.bapMaps[action] = bapExpr
e.bppMaps[action] = bppExpr
}
e.mappings = mappings
e.mappingSource = source
log.Infof(
context.Background(),
"Loaded %d BAP mappings and %d BPP mappings from %s",
len(e.bapMaps),
len(e.bppMaps),
source,
)
return nil
}
// Transform applies the appropriate mapping based on role and action
func (e *MappingEngine) Transform(ctx context.Context, action string, req map[string]interface{}, role string) ([]byte, error) {
e.mutex.RLock()
defer e.mutex.RUnlock()
var expr jsonata.Expression
var found bool
// Select appropriate mapping based on role
switch role {
case "bap":
expr, found = e.bapMaps[action]
case "bpp":
expr, found = e.bppMaps[action]
default:
return json.Marshal(req)
}
// If no mapping found, return original request
if !found || expr == nil {
log.Debugf(ctx, "No mapping found for action: %s, role: %s", action, role)
return json.Marshal(req)
}
// Marshal request for JSONata evaluation
input, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request for mapping: %w", err)
}
// Apply JSONata transformation
result, err := expr.Evaluate(input, nil)
if err != nil {
return nil, fmt.Errorf("JSONata evaluation failed: %w", err)
}
log.Debugf(ctx, "Successfully transformed %s request using %s mapping, %s", action, role, result)
return result, nil
}
// ReloadMappings reloads all mapping files (useful for hot-reload scenarios)
func (e *MappingEngine) ReloadMappings() error {
e.mutex.Lock()
defer e.mutex.Unlock()
return e.loadBuiltinMappings()
}
// GetMappingInfo returns information about loaded mappings
func (e *MappingEngine) GetMappingInfo() map[string]interface{} {
e.mutex.RLock()
defer e.mutex.RUnlock()
bapActions := make([]string, 0, len(e.bapMaps))
for action := range e.bapMaps {
bapActions = append(bapActions, action)
}
bppActions := make([]string, 0, len(e.bppMaps))
for action := range e.bppMaps {
bppActions = append(bppActions, action)
}
return map[string]interface{}{
"bap_mappings": bapActions,
"bpp_mappings": bppActions,
"mappings_source": e.mappingSource,
"action_count": len(e.mappings),
}
}
func validateConfig(cfg *Config) error {
if cfg == nil {
return errors.New("config cannot be nil")
}
if cfg.Role != "bap" && cfg.Role != "bpp" {
return errors.New("role must be either 'bap' or 'bpp'")
}
if cfg.MappingsFile == "" {
return errors.New("mappingsFile is required")
}
return nil
}

View File

@@ -0,0 +1,240 @@
package reqmapper
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"sync"
"testing"
)
func resetEngineState(t *testing.T) {
t.Helper()
engineInstance = nil
engineOnce = sync.Once{}
}
func testMappingsFile(t *testing.T) string {
t.Helper()
path := filepath.Join("testdata", "mappings.yaml")
if _, err := os.Stat(path); err != nil {
t.Fatalf("test mappings file missing: %v", err)
}
return path
}
func initTestEngine(t *testing.T) *MappingEngine {
t.Helper()
resetEngineState(t)
engine, err := initMappingEngine(&Config{
Role: "bap",
MappingsFile: testMappingsFile(t),
})
if err != nil {
t.Fatalf("failed to init mapping engine: %v", err)
}
return engine
}
func TestNewReqMapper_InvalidConfig(t *testing.T) {
t.Run("nil config", func(t *testing.T) {
if _, err := NewReqMapper(nil); err == nil {
t.Fatalf("expected error for nil config")
}
})
t.Run("invalid role", func(t *testing.T) {
if _, err := NewReqMapper(&Config{Role: "invalid"}); err == nil {
t.Fatalf("expected error for invalid role")
}
})
}
func TestNewReqMapper_MiddlewareTransformsRequest(t *testing.T) {
resetEngineState(t)
mw, err := NewReqMapper(&Config{
Role: "bap",
MappingsFile: testMappingsFile(t),
})
if err != nil {
t.Fatalf("NewReqMapper returned error: %v", err)
}
startLocation := map[string]interface{}{
"gps": "12.9716,77.5946",
"city": "Bengaluru",
}
endLocation := map[string]interface{}{
"gps": "13.0827,80.2707",
"city": "Chennai",
}
requestPayload := map[string]interface{}{
"context": map[string]interface{}{
"domain": "retail",
"action": "search",
"version": "1.1.0",
"bap_id": "bap.example",
"bap_uri": "https://bap.example/api",
"transaction_id": "txn-1",
"message_id": "msg-1",
"timestamp": "2023-01-01T10:00:00Z",
},
"message": map[string]interface{}{
"intent": map[string]interface{}{
"item": map[string]interface{}{
"id": "item-1",
},
"provider": map[string]interface{}{
"id": "provider-1",
},
"fulfillment": map[string]interface{}{
"start": map[string]interface{}{
"location": startLocation,
},
"end": map[string]interface{}{
"location": endLocation,
},
},
},
},
}
body, err := json.Marshal(requestPayload)
if err != nil {
t.Fatalf("failed to marshal request payload: %v", err)
}
var captured map[string]interface{}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
data, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("failed to read request in handler: %v", err)
}
if err := json.Unmarshal(data, &captured); err != nil {
t.Fatalf("failed to unmarshal transformed payload: %v", err)
}
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
rec := httptest.NewRecorder()
mw(next).ServeHTTP(rec, req)
if captured == nil {
t.Fatalf("middleware did not forward request to next handler")
}
message, ok := captured["message"].(map[string]interface{})
if !ok {
t.Fatalf("expected message field in transformed payload")
}
filters, ok := message["filters"].(map[string]interface{})
if !ok {
t.Fatalf("expected filters in transformed payload")
}
if pickup := filters["pickup"]; !reflect.DeepEqual(pickup, startLocation) {
t.Fatalf("pickup location mismatch\ngot: %#v\nwant: %#v", pickup, startLocation)
}
if drop := filters["drop"]; !reflect.DeepEqual(drop, endLocation) {
t.Fatalf("drop location mismatch\ngot: %#v\nwant: %#v", drop, endLocation)
}
}
func TestMappingEngine_TransformFallbackForUnknownAction(t *testing.T) {
engine := initTestEngine(t)
req := map[string]interface{}{
"context": map[string]interface{}{
"action": "unknown_action",
},
"message": map[string]interface{}{},
}
expected, err := json.Marshal(req)
if err != nil {
t.Fatalf("failed to marshal expected payload: %v", err)
}
result, err := engine.Transform(context.Background(), "unknown_action", req, "bap")
if err != nil {
t.Fatalf("Transform returned error: %v", err)
}
if !bytes.Equal(result, expected) {
t.Fatalf("expected Transform to return original payload")
}
}
func TestMappingEngine_TransformFallbackForUnknownRole(t *testing.T) {
engine := initTestEngine(t)
req := map[string]interface{}{
"context": map[string]interface{}{
"action": "search",
},
"message": map[string]interface{}{},
}
expected, err := json.Marshal(req)
if err != nil {
t.Fatalf("failed to marshal expected payload: %v", err)
}
result, err := engine.Transform(context.Background(), "search", req, "unknown-role")
if err != nil {
t.Fatalf("Transform returned error: %v", err)
}
if !bytes.Equal(result, expected) {
t.Fatalf("expected Transform to return original payload when role is unknown")
}
}
func TestMappingEngine_ReloadMappings(t *testing.T) {
engine := initTestEngine(t)
engine.mutex.RLock()
originalBAP := len(engine.bapMaps)
originalBPP := len(engine.bppMaps)
engine.mutex.RUnlock()
if originalBAP == 0 || originalBPP == 0 {
t.Fatalf("expected test mappings to be loaded")
}
engine.mutex.Lock()
for action := range engine.bapMaps {
delete(engine.bapMaps, action)
break
}
engine.mutex.Unlock()
engine.mutex.RLock()
if len(engine.bapMaps) == originalBAP {
engine.mutex.RUnlock()
t.Fatalf("expected BAP map to be altered before reload")
}
engine.mutex.RUnlock()
if err := engine.ReloadMappings(); err != nil {
t.Fatalf("ReloadMappings returned error: %v", err)
}
engine.mutex.RLock()
defer engine.mutex.RUnlock()
if len(engine.bapMaps) != originalBAP {
t.Fatalf("expected BAP mappings to be reloaded, got %d want %d", len(engine.bapMaps), originalBAP)
}
if len(engine.bppMaps) != originalBPP {
t.Fatalf("expected BPP mappings to be reloaded, got %d want %d", len(engine.bppMaps), originalBPP)
}
}

View File

@@ -0,0 +1,17 @@
mappings:
search:
bapMappings: |
{
"context": $.context,
"message": {
"filters": {
"pickup": $.message.intent.fulfillment.start.location,
"drop": $.message.intent.fulfillment.end.location
}
}
}
bppMappings: |
{
"context": $.context,
"message": $.message
}

View File

@@ -217,13 +217,35 @@ func (m *Manager) OtelSetup(ctx context.Context, cfg *Config) (*telemetry.Provid
if closer != nil {
m.closers = append(m.closers, func() {
if err := closer(); err != nil {
panic(err)
log.Errorf(context.Background(), err, "Failed to shutdown telemetry provider")
}
})
}
return provider, nil
}
// TransportWrapper returns a TransportWrapper instance based on the provided configuration.
func (m *Manager) TransportWrapper(ctx context.Context, cfg *Config) (definition.TransportWrapper, error) {
twp, err := provider[definition.TransportWrapperProvider](m.plugins, cfg.ID)
if err != nil {
return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err)
}
config := make(map[string]any, len(cfg.Config))
for k, v := range cfg.Config {
config[k] = v
}
wrapper, closer, err := twp.New(ctx, config)
if err != nil {
return nil, err
}
if closer != nil {
m.closers = append(m.closers, closer)
}
return wrapper, nil
}
// Step returns a Step instance based on the provided configuration.
func (m *Manager) Step(ctx context.Context, cfg *Config) (definition.Step, error) {
sp, err := provider[definition.StepProvider](m.plugins, cfg.ID)