573 - New plugin - schema mapper
This commit is contained in:
105
CONFIG.md
105
CONFIG.md
@@ -624,6 +624,109 @@ middleware:
|
||||
|
||||
---
|
||||
|
||||
#### 11. Reqmapper Plugin
|
||||
|
||||
**Purpose**: Transform Beckn payloads between protocol versions or shapes using JSONata before the request continues through the handler. Mount it inside the `middleware` list wherever translation is required.
|
||||
|
||||
```yaml
|
||||
middleware:
|
||||
- id: reqmapper
|
||||
config:
|
||||
role: bap # Use `bpp` when running inside a BPP handler
|
||||
mappingsFile: ./config/mappings.yaml
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
- `role`: Required. Determines which JSONata expression is evaluated (`bapMappings` or `bppMappings`) for the current action.
|
||||
- `mappingsFile`: Required. Absolute or relative path to a YAML file that contains the JSONata expressions for every action.
|
||||
|
||||
**Mapping file structure**:
|
||||
```yaml
|
||||
mappings:
|
||||
<action-name>:
|
||||
bapMappings: |
|
||||
# JSONata expression applied when `role: bap`
|
||||
bppMappings: |
|
||||
# JSONata expression applied when `role: bpp`
|
||||
```
|
||||
Each action entry is optional—if no mapping exists for the current action, the original request body is passed through unchanged. JSONata expressions receive the entire Beckn request as input (`$`) and must return the full payload that should replace it.
|
||||
|
||||
**Sample mapping file**:
|
||||
```yaml
|
||||
mappings:
|
||||
search:
|
||||
bapMappings: |
|
||||
{
|
||||
"context": {
|
||||
"action": "discover",
|
||||
"version": "2.0.0",
|
||||
"domain": "beckn.one:retail",
|
||||
"bap_id": $.context.bap_id,
|
||||
"bap_uri": $.context.bap_uri,
|
||||
"transaction_id": $.context.transaction_id,
|
||||
"message_id": $.context.message_id,
|
||||
"timestamp": $.context.timestamp
|
||||
},
|
||||
"message": {
|
||||
"filters": $.message.intent.category ? {
|
||||
"type": "jsonpath",
|
||||
"expression": "$[?(@.category.code == '" & $.message.intent.category.descriptor.code & "')]"
|
||||
} : null
|
||||
}
|
||||
}
|
||||
bppMappings: |
|
||||
{
|
||||
"context": {
|
||||
"action": "search",
|
||||
"version": "1.1.0",
|
||||
"domain": "retail",
|
||||
"bap_id": $.context.bap_id,
|
||||
"bap_uri": $.context.bap_uri,
|
||||
"transaction_id": $.context.transaction_id,
|
||||
"message_id": $.context.message_id,
|
||||
"timestamp": $.context.timestamp
|
||||
},
|
||||
"message": {
|
||||
"intent": {
|
||||
"category": $.message.filters ? {
|
||||
"descriptor": {
|
||||
"code": $substringAfter($substringBefore($.message.filters.expression, "'"), "== '")
|
||||
}
|
||||
} : null
|
||||
}
|
||||
}
|
||||
}
|
||||
on_search:
|
||||
bapMappings: |
|
||||
{
|
||||
"context": $.context,
|
||||
"message": {
|
||||
"catalog": {
|
||||
"descriptor": $.message.catalogs[0]."beckn:descriptor" ? {
|
||||
"name": $.message.catalogs[0]."beckn:descriptor"."schema:name"
|
||||
} : null
|
||||
}
|
||||
}
|
||||
}
|
||||
bppMappings: |
|
||||
{
|
||||
"context": $.context,
|
||||
"message": {
|
||||
"catalogs": [{
|
||||
"@type": "beckn:Catalog",
|
||||
"beckn:items": $.message.catalog.providers[].items[].
|
||||
{
|
||||
"@type": "beckn:Item",
|
||||
"beckn:id": id
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
The sample illustrates how a single mapping file can convert `search` requests and `on_search` responses between Beckn 1.1.0 (BAP) and Beckn 2.0.0 (BPP) payload shapes. You can define as many action entries as needed, and the plugin will compile and cache the JSONata expressions on startup.
|
||||
|
||||
---
|
||||
|
||||
## Routing Configuration
|
||||
|
||||
### Routing Rules File Structure
|
||||
@@ -1066,4 +1169,4 @@ modules:
|
||||
httpClientConfig:
|
||||
maxIdleConns: 1000
|
||||
maxIdleConnsPerHost: 200
|
||||
idleConnTimeout: 300
|
||||
idleConnTimeout: 300
|
||||
|
||||
5
go.mod
5
go.mod
@@ -20,7 +20,7 @@ require (
|
||||
|
||||
require github.com/zenazn/pkcs7pad v0.0.0-20170308005700-253a5b1f0e03
|
||||
|
||||
require golang.org/x/text v0.23.0 // indirect
|
||||
require golang.org/x/text v0.26.0 // indirect
|
||||
|
||||
require (
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
@@ -50,7 +50,7 @@ require (
|
||||
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||
github.com/woodsbury/decimal128 v1.3.0 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 // indirect
|
||||
)
|
||||
|
||||
@@ -59,6 +59,7 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7
|
||||
github.com/hashicorp/vault/api v1.16.0
|
||||
github.com/jsonata-go/jsonata v0.0.0-20250709164031-599f35f32e5f
|
||||
github.com/rabbitmq/amqp091-go v1.10.0
|
||||
github.com/redis/go-redis/v9 v9.8.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
|
||||
8
go.sum
8
go.sum
@@ -62,6 +62,8 @@ github.com/hashicorp/vault/api v1.16.0 h1:nbEYGJiAPGzT9U4oWgaaB0g+Rj8E59QuHKyA5L
|
||||
github.com/hashicorp/vault/api v1.16.0/go.mod h1:KhuUhzOD8lDSk29AtzNjgAu2kxRA9jL9NAbkFlqvkBA=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/jsonata-go/jsonata v0.0.0-20250709164031-599f35f32e5f h1:JnGon8QHtmjFPq0NcSu8OTEnQDDEgFME7gtj/NkjCUo=
|
||||
github.com/jsonata-go/jsonata v0.0.0-20250709164031-599f35f32e5f/go.mod h1:rYUEOEiieWXHNCE/eDXV/o5s7jZ2VyUzQKbqVns9pik=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
@@ -134,8 +136,10 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 h1:NusfzzA6yGQ+ua51ck7E3omNUX/JuqbFSaRGqU8CcLI=
|
||||
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -16,6 +16,7 @@ plugins=(
|
||||
"registry"
|
||||
"dediregistry"
|
||||
"reqpreprocessor"
|
||||
"reqmapper"
|
||||
"router"
|
||||
"schemavalidator"
|
||||
"schemav2validator"
|
||||
|
||||
23
pkg/plugin/implementation/reqmapper/cmd/plugin.go
Normal file
23
pkg/plugin/implementation/reqmapper/cmd/plugin.go
Normal 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{}
|
||||
84
pkg/plugin/implementation/reqmapper/cmd/plugin_test.go
Normal file
84
pkg/plugin/implementation/reqmapper/cmd/plugin_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
287
pkg/plugin/implementation/reqmapper/reqmapper.go
Normal file
287
pkg/plugin/implementation/reqmapper/reqmapper.go
Normal 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
|
||||
}
|
||||
240
pkg/plugin/implementation/reqmapper/reqmapper_test.go
Normal file
240
pkg/plugin/implementation/reqmapper/reqmapper_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
17
pkg/plugin/implementation/reqmapper/testdata/mappings.yaml
vendored
Normal file
17
pkg/plugin/implementation/reqmapper/testdata/mappings.yaml
vendored
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user