solve schema validation conflicts

This commit is contained in:
AshwiniK-protean
2025-04-23 18:28:47 +05:30
93 changed files with 11199 additions and 758 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @ashishkgGoogle @poojajoshi2 @Deepa-Mulchandani @tanyamadaan

88
.github/workflows/beckn_ci.yml vendored Normal file
View File

@@ -0,0 +1,88 @@
name: CI/CD Pipeline
on:
pull_request:
branches:
- beckn-onix-v1.0-develop
env:
APP_DIRECTORY: "shared/plugin" # Root directory to start searching from
jobs:
lint_and_test:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
timeout-minutes: 10 # Increased timeout due to additional steps
steps:
# 1. Checkout the code from the test branch (triggered by PR)
- name: Checkout code
uses: actions/checkout@v4
# 2. Set up Go environment
- name: Set up Go 1.24.0
uses: actions/setup-go@v4
with:
go-version: '1.24.0'
# 3. Install golangci-lint
- name: Install golangci-lint
run: |
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
# 4. Run golangci-lint on the entire repo, starting from the root directory
- name: Run golangci-lint
run: |
golangci-lint run ./... # This will lint all Go files in the repo and subdirectories
# 5. Run unit tests with coverage in the entire repository
- name: Run unit tests with coverage
run: |
# Create a directory to store coverage files
mkdir -p $GITHUB_WORKSPACE/coverage_files
# Find all *_test.go files and run `go test` for each
find ./ -type f -name '*_test.go' | while read test_file; do
# Get the directory of the test file
test_dir=$(dirname "$test_file")
# Get the name of the Go file associated with the test
go_file="${test_file/_test.go/.go}"
# Run tests and store coverage for each Go file in a separate file
echo "Running tests in $test_dir for $go_file"
go test -v -coverprofile=$GITHUB_WORKSPACE/coverage_files/coverage_$(basename "$go_file" .go).out $test_dir
done
# 6. List the generated coverage files for debugging purposes
#- name: List coverage files
#run: |
#echo "Listing all generated coverage files:"
#ls -l $GITHUB_WORKSPACE/coverage_files/
# 7. Check coverage for each generated coverage file
- name: Check coverage for each test file
run: |
# Loop through each coverage file in the coverage_files directory
for coverage_file in $GITHUB_WORKSPACE/coverage_files/coverage_*.out; do
echo "Checking coverage for $coverage_file"
# Get the coverage percentage for each file
coverage=$(go tool cover -func=$coverage_file | grep total | awk '{print $3}' | sed 's/%//')
echo "Coverage for $coverage_file: $coverage%"
# If coverage is below threshold (90%), fail the job
if (( $(echo "$coverage < 80" | bc -l) )); then
echo "Coverage for $coverage_file is below 90%. Failing the job."
exit 1
fi
done
# 7. Build the Go code
#- name: Build Go code
# run: |
# go build -o myapp ${{ env.APP_DIRECTORY }}/...
# if [ ! -f myapp ]; then
# echo "Build failed: myapp executable was not created."
# exit 1
# else
# echo "Build succeeded: myapp executable created."
# fi

85
.github/workflows/beckn_ci_test.yml vendored Normal file
View File

@@ -0,0 +1,85 @@
name: CI/CD Test Pipeline
on:
pull_request:
branches:
- beckn-onix-v1.0-develop
env:
APP_DIRECTORY: "shared/plugin"
jobs:
lint_and_test:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
timeout-minutes: 10
outputs:
coverage_ok: ${{ steps.coverage_check.outputs.coverage_ok }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go 1.24.0
uses: actions/setup-go@v4
with:
go-version: '1.24.0'
- name: Install golangci-lint
run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
- name: Run golangci-lint
run: golangci-lint run ./...
- name: Run unit tests with coverage
run: |
mkdir -p $GITHUB_WORKSPACE/coverage_files
test_files=$(find ./ -type f -name '*_test.go')
if [ -z "$test_files" ]; then
echo "No test cases found. Skipping."
exit 0
fi
for test_file in $test_files; do
test_dir=$(dirname "$test_file")
go_file="${test_file/_test.go/.go}"
echo "Running tests in $test_dir for $go_file"
go test -v -coverprofile=$GITHUB_WORKSPACE/coverage_files/coverage_$(basename "$go_file" .go).out $test_dir || echo "Tests failed, but continuing."
done
- name: Check coverage for each test file
id: coverage_check
run: |
echo "coverage_ok=true" >> $GITHUB_OUTPUT
coverage_files=$(find $GITHUB_WORKSPACE/coverage_files -name "coverage_*.out")
if [ -z "$coverage_files" ]; then
echo "No coverage files found. Skipping coverage check."
exit 0
fi
for coverage_file in $coverage_files; do
echo "Checking coverage for $coverage_file"
coverage=$(go tool cover -func=$coverage_file | grep total | awk '{print $3}' | sed 's/%//')
echo "Coverage: $coverage%"
if (( $(echo "$coverage < 90" | bc -l) )); then
echo "coverage_ok=false" >> $GITHUB_OUTPUT
break
fi
done
require_exception_approval:
needs: lint_and_test
if: needs.lint_and_test.outputs.coverage_ok == 'false'
runs-on: ubuntu-latest
environment:
name: coverage-exception
url: https://your-coverage-dashboard.com # Optional
steps:
- name: Manual approval required
run: echo "Coverage < 90%. Approval required to continue."
proceed_with_merge:
needs: [lint_and_test, require_exception_approval]
if: |
needs.lint_and_test.outputs.coverage_ok == 'true' || success()
runs-on: ubuntu-latest
steps:
- name: Proceed with merge
run: echo "Coverage requirement met or exception approved. Merge allowed."

36
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Go CI
on:
pull_request:
branches:
- beck-onix-v1.0-develop
- beck-onix-v1.0
push:
branches:
- beck-onix-v1.0-develop
- beck-onix-v1.0
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: '1.20'
- name: Install dependencies
run: go mod tidy
- name: Run tests
run: go test -coverprofile=coverage.out ./...
- name: Check coverage
run: |
coverage=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
if (( $(echo "$coverage < 90" | bc -l) )); then
echo "Coverage is below 90%"
exit 1
fi
- name: Run golangci-lint
run: golangci-lint run
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
files: ./coverage.out

10
.gitignore vendored
View File

@@ -129,3 +129,13 @@ dist
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Ignore compiled shared object files
*.so
# Ignore coverage output files
coverage.out
coverage.html
# Ignore the schema directory used for testing
/schemas/

25
Dockerfile.adapter Normal file
View File

@@ -0,0 +1,25 @@
FROM golang:1.24-bullseye AS builder
WORKDIR /app
COPY cmd/adapter ./cmd/adapter
COPY core/ ./core
COPY pkg/ ./pkg
COPY go.mod .
COPY go.sum .
RUN go mod download
RUN go build -o server cmd/adapter/main.go
# Create a minimal runtime image
FROM cgr.dev/chainguard/wolfi-base
# ✅ Alpine is removed; using minimal Debian
WORKDIR /app
# Copy only the built binary and plugin
COPY --from=builder /app/server .
# Expose port 8080
EXPOSE 8080
# Run the Go server with the config flag from environment variable.
CMD ["sh", "-c", "./server --config=${CONFIG_FILE}"]

181
cmd/adapter/main.go Normal file
View File

@@ -0,0 +1,181 @@
package main
import (
"context"
"flag"
"fmt"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
"gopkg.in/yaml.v2"
"github.com/beckn/beckn-onix/core/module"
"github.com/beckn/beckn-onix/core/module/handler"
"github.com/beckn/beckn-onix/pkg/log"
"github.com/beckn/beckn-onix/pkg/plugin"
)
// Config struct holds all configurations.
type Config struct {
AppName string `yaml:"appName"`
Log log.Config `yaml:"log"`
PluginManager *plugin.ManagerConfig `yaml:"pluginManager"`
Modules []module.Config `yaml:"modules"`
HTTP httpConfig `yaml:"http"`
}
type httpConfig struct {
Port string `yaml:"port"`
Timeouts timeoutConfig `yaml:"timeout"`
}
type timeoutConfig struct {
Read time.Duration `yaml:"read"`
Write time.Duration `yaml:"write"`
Idle time.Duration `yaml:"idle"`
}
var configPath string
var runFunc = run
func main() {
// Define and parse command-line flags.
flag.StringVar(&configPath, "config", "../../config/onix/adapter.yaml", "Path to the configuration file")
flag.Parse()
// Use custom log for initial setup messages.
log.Infof(context.Background(), "Starting application with config: %s", configPath)
// Run the application within a context.
if err := runFunc(context.Background(), configPath); err != nil {
log.Fatalf(context.Background(), err, "Application failed: %v", err)
}
log.Infof(context.Background(), "Application finished")
}
// initConfig loads and validates the configuration.
func initConfig(ctx context.Context, path string) (*Config, error) {
// Open the configuration file.
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("could not open config file: %w", err)
}
defer file.Close()
// Decode the YAML configuration.
var cfg Config
if err := yaml.NewDecoder(file).Decode(&cfg); err != nil {
return nil, fmt.Errorf("could not decode config: %w", err)
}
log.Debugf(ctx, "Read config: %#v", cfg)
// Validate the configuration.
if err := validateConfig(&cfg); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
return &cfg, nil
}
// validateConfig validates the configuration.
func validateConfig(cfg *Config) error {
if strings.TrimSpace(cfg.AppName) == "" {
return fmt.Errorf("missing app name")
}
if strings.TrimSpace(cfg.HTTP.Port) == "" {
return fmt.Errorf("missing port")
}
return nil
}
// newServer creates and initializes the HTTP server.
func newServer(ctx context.Context, mgr handler.PluginManager, cfg *Config) (http.Handler, error) {
mux := http.NewServeMux()
err := module.Register(ctx, cfg.Modules, mux, mgr)
if err != nil {
return nil, fmt.Errorf("failed to register modules: %w", err)
}
return mux, nil
}
var newManagerFunc = plugin.NewManager
var newServerFunc = newServer
// run encapsulates the application logic.
func run(ctx context.Context, configPath string) error {
closers := []func(){}
// Initialize configuration and logger.
cfg, err := initConfig(ctx, configPath)
if err != nil {
return fmt.Errorf("failed to initialize config: %w", err)
}
log.Infof(ctx, "Initializing logger with config: %+v", cfg.Log)
if err := log.InitLogger(cfg.Log); err != nil {
return fmt.Errorf("failed to initialize logger: %w", err)
}
// Initialize plugin manager.
log.Infof(ctx, "Initializing plugin manager")
mgr, closer, err := newManagerFunc(ctx, cfg.PluginManager)
if err != nil {
return fmt.Errorf("failed to create plugin manager: %w", err)
}
closers = append(closers, closer)
log.Debug(ctx, "Plugin manager loaded.")
// Initialize HTTP server.
log.Infof(ctx, "Initializing HTTP server")
srv, err := newServerFunc(ctx, mgr, cfg)
if err != nil {
return fmt.Errorf("failed to initialize server: %w", err)
}
// Configure HTTP server.
httpServer := &http.Server{
Addr: net.JoinHostPort("", cfg.HTTP.Port),
Handler: srv,
ReadTimeout: cfg.HTTP.Timeouts.Read * time.Second,
WriteTimeout: cfg.HTTP.Timeouts.Write * time.Second,
IdleTimeout: cfg.HTTP.Timeouts.Idle * time.Second,
}
// Start HTTP server.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
log.Infof(ctx, "Server listening on %s", httpServer.Addr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Errorf(ctx, fmt.Errorf("http server ListenAndServe: %w", err), "error listening and serving")
}
}()
// Handle shutdown.
shutdown(ctx, httpServer, &wg, closers)
wg.Wait()
log.Infof(ctx, "Server shutdown complete")
return nil
}
// shutdown handles server shutdown.
func shutdown(ctx context.Context, httpServer *http.Server, wg *sync.WaitGroup, closers []func()) {
wg.Add(1)
go func() {
defer wg.Done()
<-ctx.Done()
log.Infof(ctx, "Shutting down server...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
log.Errorf(ctx, fmt.Errorf("http server Shutdown: %w", err), "error shutting down http server")
}
// Call all closer functions.
for _, closer := range closers {
closer()
}
}()
}

429
cmd/adapter/main_test.go Normal file
View File

@@ -0,0 +1,429 @@
package main
import (
"context"
"errors"
"flag"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/beckn/beckn-onix/core/module"
"github.com/beckn/beckn-onix/core/module/handler"
"github.com/beckn/beckn-onix/pkg/plugin"
"github.com/beckn/beckn-onix/pkg/plugin/definition"
"github.com/stretchr/testify/mock"
)
// MockPluginManager implements handler.PluginManager for testing.
type MockPluginManager struct {
mock.Mock
}
// Middleware returns a middleware function based on the provided configuration.
func (m *MockPluginManager) Middleware(ctx context.Context, cfg *plugin.Config) (func(http.Handler) http.Handler, error) {
return nil, nil
}
// SignValidator returns a mock implementation of the Verifier interface.
func (m *MockPluginManager) SignValidator(ctx context.Context, cfg *plugin.Config) (definition.SignValidator, error) {
return nil, nil
}
// Validator returns a mock implementation of the SchemaValidator interface.
func (m *MockPluginManager) Validator(ctx context.Context, cfg *plugin.Config) (definition.SchemaValidator, error) {
return nil, nil
}
// Router returns a mock implementation of the Router interface.
func (m *MockPluginManager) Router(ctx context.Context, cfg *plugin.Config) (definition.Router, error) {
return nil, nil
}
// Publisher returns a mock implementation of the Publisher interface.
func (m *MockPluginManager) Publisher(ctx context.Context, cfg *plugin.Config) (definition.Publisher, error) {
return nil, nil
}
// Signer returns a mock implementation of the Signer interface.
func (m *MockPluginManager) Signer(ctx context.Context, cfg *plugin.Config) (definition.Signer, error) {
return nil, nil
}
// Step returns a mock implementation of the Step interface.
func (m *MockPluginManager) Step(ctx context.Context, cfg *plugin.Config) (definition.Step, error) {
return nil, nil
}
// Cache returns a mock implementation of the Cache interface.
func (m *MockPluginManager) Cache(ctx context.Context, cfg *plugin.Config) (definition.Cache, error) {
return nil, nil
}
// KeyManager returns a mock implementation of the KeyManager interface.
func (m *MockPluginManager) KeyManager(ctx context.Context, cache definition.Cache, rLookup definition.RegistryLookup, cfg *plugin.Config) (definition.KeyManager, error) {
return nil, nil
}
// SchemaValidator returns a mock implementation of the SchemaValidator interface.
func (m *MockPluginManager) SchemaValidator(ctx context.Context, cfg *plugin.Config) (definition.SchemaValidator, error) {
return nil, nil
}
// mockRun is a mock implementation of the `run` function, simulating a successful run.
func mockRun(ctx context.Context, configPath string) error {
return nil // Simulate a successful run
}
// TestMainFunction tests the main function execution, including command-line argument parsing.
func TestMainFunction(t *testing.T) {
// Backup original run function and restore it after test
origRun := runFunc
defer func() { runFunc = origRun }()
runFunc = mockRun
origArgs := os.Args
defer func() { os.Args = origArgs }()
// Set mock command-line arguments
os.Args = []string{"cmd", "-config=../../config/test-config.yaml"}
fs := flag.NewFlagSet("test", flag.ExitOnError)
fs.StringVar(&configPath, "config", "../../config/clientSideHandler-config.yaml", "Path to the configuration file")
if err := fs.Parse(os.Args[1:]); err != nil {
t.Fatalf("Failed to parse flags: %v", err)
}
main()
}
func TestRunSuccess(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
configPath := "../test/validConfig.yaml"
// Mock dependencies
originalNewManager := newManagerFunc
newManagerFunc = func(ctx context.Context, cfg *plugin.ManagerConfig) (*plugin.Manager, func(), error) {
return &plugin.Manager{}, func() {}, nil
}
defer func() { newManagerFunc = originalNewManager }()
originalNewServer := newServerFunc
newServerFunc = func(ctx context.Context, mgr handler.PluginManager, cfg *Config) (http.Handler, error) {
return http.NewServeMux(), nil
}
defer func() { newServerFunc = originalNewServer }()
if err := run(ctx, filepath.Clean(configPath)); err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
}
// TestRunFailure validates failure scenarios for the run function.
func TestRunFailure(t *testing.T) {
tests := []struct {
name string
configData string
mockMgr func() (*MockPluginManager, func(), error)
mockLogger func(cfg *Config) error
mockServer func(ctx context.Context, mgr handler.PluginManager, cfg *Config) (http.Handler, error)
expectedErr string
}{
{
name: "Invalid Config File",
configData: "invalid_config.yaml",
mockMgr: func() (*MockPluginManager, func(), error) {
return &MockPluginManager{}, func() {}, nil
},
mockLogger: func(cfg *Config) error {
return nil
},
mockServer: func(ctx context.Context, mgr handler.PluginManager, cfg *Config) (http.Handler, error) {
return nil, errors.New("failed to start server")
},
expectedErr: "failed to initialize config: invalid config: missing app name",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
testFilePath := tt.configData
mockConfig := `invalid: "config"`
err := os.WriteFile(testFilePath, []byte(mockConfig), 0644)
if err != nil {
t.Errorf("Failed to create test config file: %v", err)
}
defer os.Remove(testFilePath)
// Mock dependencies
originalNewManager := newManagerFunc
// newManagerFunc = func(ctx context.Context, cfg *plugin.ManagerConfig) (*plugin.Manager, func(), error) {
// return tt.mockMgr()
// }
newManagerFunc = nil
defer func() { newManagerFunc = originalNewManager }()
originalNewServer := newServerFunc
newServerFunc = func(ctx context.Context, mgr handler.PluginManager, cfg *Config) (http.Handler, error) {
return tt.mockServer(ctx, mgr, cfg)
}
defer func() { newServerFunc = originalNewServer }()
// Run function
err = run(ctx, testFilePath)
if err == nil {
t.Errorf("Expected error, but got nil")
} else if err.Error() != tt.expectedErr {
t.Errorf("Expected error '%s', but got '%s'", tt.expectedErr, err.Error())
}
})
}
}
// TestInitConfigSuccess tests the successful initialization of the config.
func TestInitConfigSuccess(t *testing.T) {
tests := []struct {
name string
configData string
}{
{
name: "Valid Config",
configData: `
appName: "TestApp"
http:
port: "8080"
timeout:
read: 5
write: 5
idle: 10
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
configPath := "test_config_success.yaml"
defer os.Remove(configPath)
err := os.WriteFile(configPath, []byte(tt.configData), 0644)
if err != nil {
t.Errorf("Failed to create test config file: %v", err)
}
_, err = initConfig(context.Background(), configPath)
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
})
}
}
// TestInitConfigFailure tests failure scenarios for config initialization.
func TestInitConfigFailure(t *testing.T) {
tests := []struct {
name string
configData string
expectedErr string
}{
{
name: "Invalid YAML Format",
configData: `appName: "TestApp"\nhttp: { invalid_yaml }`,
expectedErr: "could not decode config",
},
{
name: "Missing Required Fields",
configData: `appName: ""\nhttp:\n timeout:\n read: 5\n`,
expectedErr: "could not decode config: yaml: did not find expected key",
},
{
name: "Non-Existent File",
configData: "",
expectedErr: "could not open config file",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
configPath := "test_config_failure.yaml"
if tt.configData != "" {
err := os.WriteFile(configPath, []byte(tt.configData), 0644)
if err != nil {
t.Errorf("Failed to create test config file: %v", err)
}
defer os.Remove(configPath)
} else {
// Ensure file does not exist for non-existent file test
os.Remove(configPath)
}
_, err := initConfig(context.Background(), configPath)
if err == nil {
t.Errorf("Expected error but got nil")
} else if !strings.Contains(err.Error(), tt.expectedErr) {
t.Errorf("Expected error containing '%s', but got '%s'", tt.expectedErr, err.Error())
}
})
}
}
// TestNewServerSuccess tests successful server creation.
func TestNewServerSuccess(t *testing.T) {
tests := []struct {
name string
modules []module.Config
}{
{
name: "Successful server creation with no modules",
modules: []module.Config{}, // No modules to simplify the test
},
}
mockMgr := new(MockPluginManager) // Mocking PluginManager
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &Config{
Modules: tt.modules,
HTTP: httpConfig{
Port: "8080",
Timeouts: timeoutConfig{
Read: 5,
Write: 5,
Idle: 10,
},
},
}
handler, err := newServer(context.Background(), mockMgr, cfg)
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
if handler == nil {
t.Errorf("Expected handler to be non-nil, but got nil")
}
})
}
}
// TestNewServerFailure tests failure scenarios when creating a server.
func TestNewServerFailure(t *testing.T) {
tests := []struct {
name string
modules []module.Config
}{
{
name: "Module registration failure",
modules: []module.Config{
{
Name: "InvalidModule",
Path: "/invalid",
},
},
},
}
mockMgr := new(MockPluginManager) // Mocking PluginManager
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &Config{
Modules: tt.modules,
HTTP: httpConfig{
Port: "8080",
Timeouts: timeoutConfig{
Read: 5,
Write: 5,
Idle: 10,
},
},
}
handler, err := newServer(context.Background(), mockMgr, cfg)
if err == nil {
t.Errorf("Expected an error, but got nil")
}
if handler != nil {
t.Errorf("Expected handler to be nil, but got a non-nil value")
}
})
}
}
// TestValidateConfigSuccess tests validation of a correct config.
func TestValidateConfigSuccess(t *testing.T) {
tests := []struct {
name string
cfg Config
}{
{
name: "Valid Config",
cfg: Config{
AppName: "TestApp",
HTTP: httpConfig{
Port: "8080",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateConfig(&tt.cfg)
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
})
}
}
// TestValidateConfigFailure tests validation failures for incorrect config.
func TestValidateConfigFailure(t *testing.T) {
tests := []struct {
name string
cfg Config
expectedErr string
}{
{
name: "Missing AppName",
cfg: Config{
AppName: "",
HTTP: httpConfig{
Port: "8080",
},
},
expectedErr: "missing app name",
},
{
name: "Missing Port",
cfg: Config{
AppName: "TestApp",
HTTP: httpConfig{
Port: "",
},
},
expectedErr: "missing port",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateConfig(&tt.cfg)
if err == nil {
t.Errorf("Expected error '%s', but got nil", tt.expectedErr)
} else if err.Error() != tt.expectedErr {
t.Errorf("Expected error '%s', but got '%s'", tt.expectedErr, err.Error())
}
})
}
}

37
cmd/test/validConfig.yaml Normal file
View File

@@ -0,0 +1,37 @@
appName: "testAdapter"
log:
level: debug
destinations:
- type: stdout
context_keys:
- transaction_id
- message_id
http:
port: 8080
timeout:
read: 30
write: 30
idle: 30
plugin:
root: "/mock/plugins"
pluginZipPath: "/mock/plugins/plugins_bundle.zip"
plugins:
- testPlugin1
- testPlugin2
modules:
- name: testModule
type: transaction
path: /testPath
targetType: msgQ
plugin:
schemaValidator:
id: testValidator
publisher:
id: testPublisher
config:
project: test-project
topic: test-topic
router:
id: testRouter
config:
routingConfigPath: "/mock/configs/testRouting-config.yaml"

View File

@@ -0,0 +1,98 @@
appName: "onix"
log:
level: debug
destinations:
- type: stdout
contextKeys:
- transaction_id
- message_id
- subscriber_id
- module_id
http:
port: 8080
timeout:
read: 30
write: 30
idle: 30
pluginManager:
root: /app/plugins
remoteRoot: /mnt/gcs/plugins/plugins_bundle.zip
modules:
- name: bapTxnReciever
path: /bap/reciever/
handler:
type: std
role: bap
registryUrl: http://localhost:8080/reg
plugins:
keyManager:
id: secretskeymanager
config:
projectID: ${projectID}
cache:
id: redis
config:
addr: 192.168.1.1:6379
schemaValidator:
id: schemavalidator
config:
schemaDir: /mnt/gcs/configs/schemas
signValidator:
id: signvalidator
publisher:
id: publisher
config:
project: ${projectID}
topic: bapNetworkReciever
router:
id: router
config:
routingConfigPath: /mnt/gcs/configs/bapTxnReciever-routing.yaml
middleware:
- id: reqpreprocessor
config:
uuidKeys: transaction_id,message_id
role: bap
steps:
- validateSign
- addRoute
- validateSchema
- name: bapTxnCaller
path: /bap/caller/
handler:
type: std
registryUrl: http://localhost:8080/reg
role: bap
plugins:
keyManager:
id: secretskeymanager
config:
projectID: ${projectID}
cache:
id: redis
config:
addr: 192.168.1.1:6379
schemaValidator:
id: schemavalidator
config:
schemaDir: /mnt/gcs/configs/schemas
signer:
id: signer
publisher:
id: publisher
config:
project: ${projectID}
topic: bapNetworkReciever
router:
id: router
config:
routingConfigPath: /mnt/gcs/configs/bapTxnCaller-routing.yaml
middleware:
- id: reqpreprocessor
config:
uuidKeys: transaction_id,message_id
role: bap
steps:
- validateSchema
- addRoute
- sign

View File

@@ -0,0 +1,25 @@
routingRules:
- domain: "ONDC:TRV10"
version: "2.0.0"
routingType: "bpp"
target:
url: "https://gateway.example.com"
endpoints:
- search
- domain: "ONDC:TRV10"
version: "2.0.0"
routingType: "bpp"
endpoints:
- select
- init
- confirm
- status
- cancel
- domain: "ONDC:TRV12"
version: "2.0.0"
routingType: "bpp"
endpoints:
- select
- init
- confirm
- status

View File

@@ -0,0 +1,20 @@
routingRules:
- domain: "ONDC:TRV10"
version: "2.0.0"
routingType: "url"
target:
url: "https://services-backend/trv/v1"
endpoints:
- on_select
- on_init
- on_confirm
- on_status
- on_update
- on_cancel
- domain: "ONDC:TRV10"
version: "2.0.0"
routingType: "msgq"
target:
topic_id: "trv_topic_id1"
endpoints:
- on_search

View File

@@ -0,0 +1,8 @@
plugins:
- gcpAuthMdw
- nopsigner
- router
- publisher
- reqpreprocessor
- nopschemavalidator
- nopsignvalidator

View File

@@ -0,0 +1,99 @@
appName: "onix"
log:
level: debug
destinations:
- type: stdout
contextKeys:
- transaction_id
- message_id
- subscriber_id
- module_id
http:
port: 8080
timeout:
read: 30
write: 30
idle: 30
pluginManager:
root: /app/plugins
remoteRoot: /mnt/gcs/plugins/plugins_bundle.zip
modules:
- name: bppTxnReciever
path: /bpp/reciever/
handler:
type: std
role: bpp
subscriberId: bpp1
registryUrl: http://localhost:8080/reg
plugins:
keyManager:
id: secretskeymanager
config:
projectID: ${projectID}
cache:
id: redis
config:
addr: 192.168.1.1:6379
schemaValidator:
id: schemavalidator
config:
schemaDir: /mnt/gcs/configs/schemas
signValidator:
id: signvalidator
publisher:
id: publisher
config:
project: ${projectID}
topic: bapNetworkReciever
router:
id: router
config:
routingConfigPath: /mnt/gcs/configs/bppTxnReciever-routing.yaml
middleware:
- id: reqpreprocessor
config:
uuidKeys: transaction_id,message_id
role: bpp
steps:
- validateSign
- addRoute
- validateSchema
- name: bppTxnCaller
path: /bpp/caller/
handler:
type: std
role: bpp
registryUrl: http://localhost:8080/reg
plugins:
keyManager:
id: secretskeymanager
config:
projectID: ${projectID}
cache:
id: redis
config:
addr: 192.168.1.1:6379
schemaValidator:
id: schemavalidator
config:
schemaDir: /mnt/gcs/configs/schemas
signer:
id: signer
publisher:
id: publisher
config:
project: ${projectID}
topic: bapNetworkReciever
router:
id: router
config:
routingConfigPath: /mnt/gcs/configs/bppTxnCaller-routing.yaml
middleware:
- id: reqpreprocessor
config:
uuidKeys: transaction_id,message_id
role: bpp
steps:
- validateSchema
- addRoute
- sign

View File

@@ -0,0 +1,23 @@
routingRules:
- domain: "ONDC:TRV10"
version: "2.0.0"
routingType: "bap"
endpoints:
- on_search
- on_select
- on_init
- on_confirm
- on_status
- on_update
- on_cancel
- domain: "ONDC:TRV11"
version: "2.0.0"
routingType: "bap"
endpoints:
- on_search
- on_select
- on_init
- on_confirm
- on_status
- on_update
- on_cancel

View File

@@ -0,0 +1,30 @@
routingRules:
- domain: "ONDC:TRV10"
version: "2.0.0"
routingType: "url"
target:
url: "https://services-backend/trv/v1"
endpoints:
- select
- init
- confirm
- status
- cancel
- domain: "ONDC:TRV10"
version: "2.0.0"
routingType: "msgq"
target:
topic_id: "trv_topic_id1"
endpoints:
- search
- domain: "ONDC:TRV11"
version: "2.0.0"
routingType: "url"
target:
url: "https://services-backend/trv/v1"
endpoints:
- select
- init
- confirm

View File

@@ -0,0 +1,8 @@
plugins:
- gcpAuthMdw
- nopsigner
- router
- publisher
- reqpreprocessor
- nopschemavalidator
- nopsignvalidator

177
config/onix/adapter.yaml Normal file
View File

@@ -0,0 +1,177 @@
appName: "onix"
log:
level: debug
destinations:
- type: stdout
contextKeys:
- transaction_id
- message_id
- subscriber_id
- module_id
http:
port: 8080
timeout:
read: 30
write: 30
idle: 30
pluginManager:
root: /app/plugins
remoteRoot: /mnt/gcs/plugins/plugins_bundle.zip
modules:
- name: bapTxnReciever
path: /bap/reciever/
handler:
type: std
role: bap
registryUrl: http://localhost:8080/reg
plugins:
keyManager:
id: secretskeymanager
config:
projectID: ${projectID}
cache:
id: redis
config:
addr: 10.81.192.4:6379
schemaValidator:
id: schemavalidator
config:
schemaDir: /mnt/gcs/configs/schemas
signValidator:
id: signvalidator
publisher:
id: publisher
config:
project: ${projectID}
topic: bapNetworkReciever
router:
id: router
config:
routingConfigPath: /mnt/gcs/configs/bapTxnReciever-routing.yaml
middleware:
- id: reqpreprocessor
config:
uuidKeys: transaction_id,message_id
role: bap
steps:
- validateSign
- addRoute
- validateSchema
- name: bapTxnCaller
path: /bap/caller/
handler:
type: std
registryUrl: http://localhost:8080/reg
role: bap
plugins:
keyManager:
id: secretskeymanager
config:
projectID: ${projectID}
cache:
id: redis
config:
addr: 192.168.1.1:6379
schemaValidator:
id: schemavalidator
config:
schemaDir: /mnt/gcs/configs/schemas
signer:
id: signer
publisher:
id: publisher
config:
project: ${projectID}
topic: bapNetworkReciever
router:
id: router
config:
routingConfigPath: /mnt/gcs/configs/bapTxnCaller-routing.yaml
middleware:
- id: reqpreprocessor
config:
uuidKeys: transaction_id,message_id
role: bap
steps:
- validateSchema
- addRoute
- sign
- name: bppTxnReciever
path: /bpp/reciever/
handler:
type: std
role: bpp
subscriberId: bpp1
registryUrl: http://localhost:8080/reg
plugins:
keyManager:
id: secretskeymanager
config:
projectID: ${projectID}
cache:
id: redis
config:
addr: 192.168.1.1:6379
schemaValidator:
id: schemavalidator
config:
schemaDir: /mnt/gcs/configs/schemas
signValidator:
id: signvalidator
publisher:
id: publisher
config:
project: ${projectID}
topic: bapNetworkReciever
router:
id: router
config:
routingConfigPath: /mnt/gcs/configs/bppTxnReciever-routing.yaml
middleware:
- id: reqpreprocessor
config:
uuidKeys: transaction_id,message_id
role: bpp
steps:
- validateSign
- addRoute
- validateSchema
- name: bppTxnCaller
path: /bpp/caller/
handler:
type: std
role: bpp
registryUrl: http://localhost:8080/reg
plugins:
keyManager:
id: secretskeymanager
config:
projectID: ${projectID}
cache:
id: redis
config:
addr: 192.168.1.1:6379
schemaValidator:
id: schemavalidator
config:
schemaDir: /mnt/gcs/configs/schemas
signer:
id: signer
publisher:
id: publisher
config:
project: ${projectID}
topic: bapNetworkReciever
router:
id: router
config:
routingConfigPath: /mnt/gcs/configs/bppTxnCaller-routing.yaml
middleware:
- id: reqpreprocessor
config:
uuidKeys: transaction_id,message_id
role: bpp
steps:
- validateSchema
- addRoute
- sign

View File

@@ -0,0 +1,25 @@
routingRules:
- domain: "ONDC:TRV10"
version: "2.0.0"
routingType: "bpp"
target:
url: "https://gateway.example.com"
endpoints:
- search
- domain: "ONDC:TRV10"
version: "2.0.0"
routingType: "bpp"
endpoints:
- select
- init
- confirm
- status
- cancel
- domain: "ONDC:TRV12"
version: "2.0.0"
routingType: "bpp"
endpoints:
- select
- init
- confirm
- status

View File

@@ -0,0 +1,20 @@
routingRules:
- domain: "ONDC:TRV10"
version: "2.0.0"
routingType: "url"
target:
url: "https://services-backend/trv/v1"
endpoints:
- on_select
- on_init
- on_confirm
- on_status
- on_update
- on_cancel
- domain: "ONDC:TRV10"
version: "2.0.0"
routingType: "msgq"
target:
topic_id: "trv_topic_id1"
endpoints:
- on_search

View File

@@ -0,0 +1,23 @@
routingRules:
- domain: "ONDC:TRV10"
version: "2.0.0"
routingType: "bap"
endpoints:
- on_search
- on_select
- on_init
- on_confirm
- on_status
- on_update
- on_cancel
- domain: "ONDC:TRV11"
version: "2.0.0"
routingType: "bap"
endpoints:
- on_search
- on_select
- on_init
- on_confirm
- on_status
- on_update
- on_cancel

View File

@@ -0,0 +1,30 @@
routingRules:
- domain: "ONDC:TRV10"
version: "2.0.0"
routingType: "url"
target:
url: "https://services-backend/trv/v1"
endpoints:
- select
- init
- confirm
- status
- cancel
- domain: "ONDC:TRV10"
version: "2.0.0"
routingType: "msgq"
target:
topic_id: "trv_topic_id1"
endpoints:
- search
- domain: "ONDC:TRV11"
version: "2.0.0"
routingType: "url"
target:
url: "https://services-backend/trv/v1"
endpoints:
- select
- init
- confirm

8
config/onix/plugin.yaml Normal file
View File

@@ -0,0 +1,8 @@
plugins:
- gcpAuthMdw
- nopsigner
- router
- publisher
- reqpreprocessor
- nopschemavalidator
- nopsignvalidator

View File

@@ -0,0 +1,101 @@
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/beckn/beckn-onix/pkg/model"
"github.com/hashicorp/go-retryablehttp"
)
// Config struct to hold configuration parameters.
type Config struct {
RegisteryURL string
RetryMax int
RetryWaitMin time.Duration
RetryWaitMax time.Duration
}
// registryClient encapsulates the logic for calling the subscribe and lookup endpoints.
type registryClient struct {
config *Config
client *retryablehttp.Client
}
// NewRegisteryClient creates a new instance of Client.
func NewRegisteryClient(config *Config) *registryClient {
retryClient := retryablehttp.NewClient()
return &registryClient{config: config, client: retryClient}
}
// Subscribe calls the /subscribe endpoint with retry.
func (c *registryClient) Subscribe(ctx context.Context, subscription *model.Subscription) error {
subscribeURL := fmt.Sprintf("%s/subscribe", c.config.RegisteryURL)
jsonData, err := json.Marshal(subscription)
if err != nil {
return model.NewBadReqErr(fmt.Errorf("failed to marshal subscription data: %w", err))
}
req, err := retryablehttp.NewRequest("POST", subscribeURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("failed to send request with retry: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("subscribe request failed with status: %s", resp.Status)
}
return nil
}
// Lookup calls the /lookup endpoint with retry and returns a slice of Subscription.
func (c *registryClient) Lookup(ctx context.Context, subscription *model.Subscription) ([]model.Subscription, error) {
lookupURL := fmt.Sprintf("%s/lookUp", c.config.RegisteryURL)
jsonData, err := json.Marshal(subscription)
if err != nil {
return nil, model.NewBadReqErr(fmt.Errorf("failed to marshal subscription data: %w", err))
}
req, err := retryablehttp.NewRequest("POST", lookupURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request with retry: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("lookup request failed with status: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var results []model.Subscription
err = json.Unmarshal(body, &results)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal response body: %w", err)
}
return results, nil
}

View File

@@ -0,0 +1,231 @@
package client
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/beckn/beckn-onix/pkg/model"
"github.com/stretchr/testify/require"
)
// TestSubscribeSuccess verifies that the Subscribe function succeeds when the server responds with HTTP 200.
func TestSubscribeSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte("{}")); err != nil {
t.Errorf("failed to write response: %v", err)
}
}))
defer server.Close()
client := NewRegisteryClient(&Config{
RegisteryURL: server.URL,
RetryMax: 3,
RetryWaitMin: time.Millisecond * 100,
RetryWaitMax: time.Millisecond * 500,
})
subscription := &model.Subscription{
KeyID: "test-key",
SigningPublicKey: "test-signing-key",
EncrPublicKey: "test-encryption-key",
ValidFrom: time.Now(),
ValidUntil: time.Now().Add(24 * time.Hour),
Status: "SUBSCRIBED",
}
err := client.Subscribe(context.Background(), subscription)
if err != nil {
t.Fatalf("Subscribe() failed with error: %v", err)
}
}
// TestSubscribeFailure tests different failure scenarios using a mock client.
func TestSubscribeFailure(t *testing.T) {
tests := []struct {
name string
mockError error
}{
{
name: "Failed subscription - Internal Server Error",
mockError: errors.New("internal server error"),
},
{
name: "Failed subscription - Bad Request",
mockError: errors.New("bad request"),
},
{
name: "Request Timeout",
mockError: context.DeadlineExceeded,
},
{
name: "Network Failure",
mockError: errors.New("network failure"),
},
{
name: "JSON Marshalling Failure",
mockError: errors.New("json marshalling failure"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := NewRegisteryClient(&Config{
RetryMax: 1,
RetryWaitMin: 1 * time.Millisecond,
RetryWaitMax: 2 * time.Millisecond,
})
subscription := &model.Subscription{
KeyID: "test-key",
SigningPublicKey: "test-signing-key",
EncrPublicKey: "test-encryption-key",
ValidFrom: time.Now(),
ValidUntil: time.Now().Add(24 * time.Hour),
Status: "SUBSCRIBED",
}
if tt.name == "JSON Marshalling Failure" {
subscription = &model.Subscription{} // Example of an invalid object
}
err := client.Subscribe(context.Background(), subscription)
require.Error(t, err) // Directly checking for an error since all cases should fail
})
}
}
// TestLookupSuccess tests successful lookup scenarios.
func TestLookupSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
response := []model.Subscription{
{
Subscriber: model.Subscriber{
SubscriberID: "123",
},
KeyID: "test-key",
SigningPublicKey: "test-signing-key",
EncrPublicKey: "test-encryption-key",
ValidFrom: time.Now(),
ValidUntil: time.Now().Add(24 * time.Hour),
Status: "SUBSCRIBED",
},
}
bodyBytes, _ := json.Marshal(response)
if _, err := w.Write(bodyBytes); err != nil {
t.Errorf("failed to write response: %v", err)
}
}))
defer server.Close()
config := &Config{
RegisteryURL: server.URL,
RetryMax: 1,
RetryWaitMin: 1 * time.Millisecond,
RetryWaitMax: 2 * time.Millisecond,
}
rClient := NewRegisteryClient(config)
ctx := context.Background()
subscription := &model.Subscription{
Subscriber: model.Subscriber{
SubscriberID: "123",
},
KeyID: "test-key",
SigningPublicKey: "test-signing-key",
EncrPublicKey: "test-encryption-key",
ValidFrom: time.Now(),
ValidUntil: time.Now().Add(24 * time.Hour),
Status: "SUBSCRIBED",
}
result, err := rClient.Lookup(ctx, subscription)
require.NoError(t, err)
require.NotEmpty(t, result)
require.Equal(t, subscription.Subscriber.SubscriberID, result[0].Subscriber.SubscriberID)
}
// TestLookupFailure tests failure scenarios for the Lookup function.
func TestLookupFailure(t *testing.T) {
tests := []struct {
name string
responseBody interface{}
responseCode int
setupMock func(*httptest.Server)
}{
{
name: "Lookup failure - non 200 status",
responseBody: "Internal Server Error",
responseCode: http.StatusInternalServerError,
},
{
name: "Invalid JSON response",
responseBody: "Invalid JSON",
responseCode: http.StatusOK,
},
{
name: "Server timeout",
setupMock: func(server *httptest.Server) {
server.Config.WriteTimeout = 1 * time.Millisecond // Force timeout
},
},
{
name: "Empty response body",
responseBody: "",
responseCode: http.StatusOK,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if tc.responseCode != 0 { // Prevent WriteHeader(0) error
w.WriteHeader(tc.responseCode)
}
if tc.responseBody != nil {
if str, ok := tc.responseBody.(string); ok {
if _, err := w.Write([]byte(str)); err != nil {
t.Errorf("failed to write response: %v", err)
}
} else {
bodyBytes, _ := json.Marshal(tc.responseBody)
if _, err := w.Write(bodyBytes); err != nil {
t.Errorf("failed to write response: %v", err)
}
}
}
}))
defer server.Close()
if tc.setupMock != nil {
tc.setupMock(server)
}
config := &Config{
RegisteryURL: server.URL,
RetryMax: 0,
RetryWaitMin: 1 * time.Millisecond,
RetryWaitMax: 2 * time.Millisecond,
}
rClient := NewRegisteryClient(config)
ctx := context.Background()
subscription := &model.Subscription{
Subscriber: model.Subscriber{},
KeyID: "test-key",
SigningPublicKey: "test-signing-key",
EncrPublicKey: "test-encryption-key",
ValidFrom: time.Now(),
ValidUntil: time.Now().Add(24 * time.Hour),
Status: "SUBSCRIBED",
}
result, err := rClient.Lookup(ctx, subscription)
require.Error(t, err)
require.Empty(t, result)
})
}
}

View File

@@ -0,0 +1,55 @@
package handler
import (
"context"
"net/http"
"github.com/beckn/beckn-onix/pkg/model"
"github.com/beckn/beckn-onix/pkg/plugin"
"github.com/beckn/beckn-onix/pkg/plugin/definition"
)
// PluginManager defines an interface for managing plugins dynamically.
type PluginManager interface {
Middleware(ctx context.Context, cfg *plugin.Config) (func(http.Handler) http.Handler, error)
SignValidator(ctx context.Context, cfg *plugin.Config) (definition.SignValidator, error)
Validator(ctx context.Context, cfg *plugin.Config) (definition.SchemaValidator, error)
Router(ctx context.Context, cfg *plugin.Config) (definition.Router, error)
Publisher(ctx context.Context, cfg *plugin.Config) (definition.Publisher, error)
Signer(ctx context.Context, cfg *plugin.Config) (definition.Signer, error)
Step(ctx context.Context, cfg *plugin.Config) (definition.Step, error)
Cache(ctx context.Context, cfg *plugin.Config) (definition.Cache, error)
KeyManager(ctx context.Context, cache definition.Cache, rLookup definition.RegistryLookup, cfg *plugin.Config) (definition.KeyManager, error)
SchemaValidator(ctx context.Context, cfg *plugin.Config) (definition.SchemaValidator, error)
}
// Type defines different handler types for processing requests.
type Type string
const (
// HandlerTypeStd represents the standard handler type used for general request processing.
HandlerTypeStd Type = "std"
)
// PluginCfg holds the configuration for various plugins.
type PluginCfg struct {
SchemaValidator *plugin.Config `yaml:"schemaValidator,omitempty"`
SignValidator *plugin.Config `yaml:"signValidator,omitempty"`
Publisher *plugin.Config `yaml:"publisher,omitempty"`
Signer *plugin.Config `yaml:"signer,omitempty"`
Router *plugin.Config `yaml:"router,omitempty"`
Cache *plugin.Config `yaml:"cache,omitempty"`
KeyManager *plugin.Config `yaml:"keyManager,omitempty"`
Middleware []plugin.Config `yaml:"middleware,omitempty"`
Steps []plugin.Config
}
// Config holds the configuration for request processing handlers.
type Config struct {
Plugins PluginCfg `yaml:"plugins"`
Steps []string
Type Type
RegistryURL string `yaml:"registryUrl"`
Role model.Role
SubscriberID string `yaml:"subscriberId"`
}

View File

@@ -0,0 +1,264 @@
package handler
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/http/httputil"
"net/url"
"github.com/beckn/beckn-onix/core/module/client"
"github.com/beckn/beckn-onix/pkg/log"
"github.com/beckn/beckn-onix/pkg/model"
"github.com/beckn/beckn-onix/pkg/plugin"
"github.com/beckn/beckn-onix/pkg/plugin/definition"
"github.com/beckn/beckn-onix/pkg/response"
)
// stdHandler orchestrates the execution of defined processing steps.
type stdHandler struct {
signer definition.Signer
steps []definition.Step
signValidator definition.SignValidator
cache definition.Cache
km definition.KeyManager
schemaValidator definition.SchemaValidator
router definition.Router
publisher definition.Publisher
SubscriberID string
role model.Role
}
// NewStdHandler initializes a new processor with plugins and steps.
func NewStdHandler(ctx context.Context, mgr PluginManager, cfg *Config) (http.Handler, error) {
h := &stdHandler{
steps: []definition.Step{},
SubscriberID: cfg.SubscriberID,
role: cfg.Role,
}
// Initialize plugins.
if err := h.initPlugins(ctx, mgr, &cfg.Plugins, cfg.RegistryURL); err != nil {
return nil, fmt.Errorf("failed to initialize plugins: %w", err)
}
// Initialize steps.
if err := h.initSteps(ctx, mgr, cfg); err != nil {
return nil, fmt.Errorf("failed to initialize steps: %w", err)
}
return h, nil
}
// ServeHTTP processes an incoming HTTP request and executes defined processing steps.
func (h *stdHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx, err := h.stepCtx(r, w.Header())
if err != nil {
log.Errorf(r.Context(), err, "stepCtx(r):%v", err)
response.SendNack(r.Context(), w, err)
return
}
log.Request(r.Context(), r, ctx.Body)
// Execute processing steps.
for _, step := range h.steps {
if err := step.Run(ctx); err != nil {
log.Errorf(ctx, err, "%T.run(%v):%v", step, ctx, err)
response.SendNack(ctx, w, err)
return
}
}
// Restore request body before forwarding or publishing.
r.Body = io.NopCloser(bytes.NewReader(ctx.Body))
if ctx.Route == nil {
response.SendAck(w)
return
}
// Handle routing based on the defined route type.
route(ctx, r, w, h.publisher)
}
// stepCtx creates a new StepContext for processing an HTTP request.
func (h *stdHandler) stepCtx(r *http.Request, rh http.Header) (*model.StepContext, error) {
var bodyBuffer bytes.Buffer
if _, err := io.Copy(&bodyBuffer, r.Body); err != nil {
return nil, model.NewBadReqErr(err)
}
r.Body.Close()
subID := h.subID(r.Context())
if len(subID) == 0 {
return nil, model.NewBadReqErr(fmt.Errorf("subscriberID not set"))
}
return &model.StepContext{
Context: r.Context(),
Request: r,
Body: bodyBuffer.Bytes(),
Role: h.role,
SubID: subID,
RespHeader: rh,
}, nil
}
// subID retrieves the subscriber ID from the request context.
func (h *stdHandler) subID(ctx context.Context) string {
rSubID, ok := ctx.Value(model.ContextKeySubscriberID).(string)
if ok {
return rSubID
}
return h.SubscriberID
}
var proxyFunc = proxy
// route handles request forwarding or message publishing based on the routing type.
func route(ctx *model.StepContext, r *http.Request, w http.ResponseWriter, pb definition.Publisher) {
log.Debugf(ctx, "Routing to ctx.Route to %#v", ctx.Route)
switch ctx.Route.TargetType {
case "url":
log.Infof(ctx.Context, "Forwarding request to URL: %s", ctx.Route.URL)
proxyFunc(r, w, ctx.Route.URL)
return
case "publisher":
if pb == nil {
err := fmt.Errorf("publisher plugin not configured")
log.Errorf(ctx.Context, err, "Invalid configuration:%v", err)
response.SendNack(ctx, w, err)
return
}
log.Infof(ctx.Context, "Publishing message to: %s", ctx.Route.PublisherID)
if err := pb.Publish(ctx, ctx.Route.PublisherID, ctx.Body); err != nil {
log.Errorf(ctx.Context, err, "Failed to publish message")
http.Error(w, "Error publishing message", http.StatusInternalServerError)
response.SendNack(ctx, w, err)
return
}
default:
err := fmt.Errorf("unknown route type: %s", ctx.Route.TargetType)
log.Errorf(ctx.Context, err, "Invalid configuration:%v", err)
response.SendNack(ctx, w, err)
return
}
response.SendAck(w)
}
// proxy forwards the request to a target URL using a reverse proxy.
func proxy(r *http.Request, w http.ResponseWriter, target *url.URL) {
r.URL.Scheme = target.Scheme
r.URL.Host = target.Host
r.URL.Path = target.Path
r.Header.Set("X-Forwarded-Host", r.Host)
proxy := httputil.NewSingleHostReverseProxy(target)
log.Infof(r.Context(), "Proxying request to: %s", target)
proxy.ServeHTTP(w, r)
}
// loadPlugin is a generic function to load and validate plugins.
func loadPlugin[T any](ctx context.Context, name string, cfg *plugin.Config, mgrFunc func(context.Context, *plugin.Config) (T, error)) (T, error) {
var zero T
if cfg == nil {
log.Debugf(ctx, "Skipping %s plugin: not configured", name)
return zero, nil
}
plugin, err := mgrFunc(ctx, cfg)
if err != nil {
return zero, fmt.Errorf("failed to load %s plugin (%s): %w", name, cfg.ID, err)
}
log.Debugf(ctx, "Loaded %s plugin: %s", name, cfg.ID)
return plugin, nil
}
// loadKeyManager loads the KeyManager plugin using the provided PluginManager, cache, and registry URL.
func loadKeyManager(ctx context.Context, mgr PluginManager, cache definition.Cache, cfg *plugin.Config, regURL string) (definition.KeyManager, error) {
if cfg == nil {
log.Debug(ctx, "Skipping KeyManager plugin: not configured")
return nil, nil
}
if cache == nil {
return nil, fmt.Errorf("failed to load KeyManager plugin (%s): Cache plugin not configured", cfg.ID)
}
rClient := client.NewRegisteryClient(&client.Config{RegisteryURL: regURL})
km, err := mgr.KeyManager(ctx, cache, rClient, cfg)
if err != nil {
return nil, fmt.Errorf("failed to load cache plugin (%s): %w", cfg.ID, err)
}
log.Debugf(ctx, "Loaded Keymanager plugin: %s", cfg.ID)
return km, nil
}
// initPlugins initializes required plugins for the processor.
func (h *stdHandler) initPlugins(ctx context.Context, mgr PluginManager, cfg *PluginCfg, regURL string) error {
var err error
if h.cache, err = loadPlugin(ctx, "Cache", cfg.Cache, mgr.Cache); err != nil {
return err
}
if h.km, err = loadKeyManager(ctx, mgr, h.cache, cfg.KeyManager, regURL); err != nil {
return err
}
if h.signValidator, err = loadPlugin(ctx, "SignValidator", cfg.SignValidator, mgr.SignValidator); err != nil {
return err
}
if h.schemaValidator, err = loadPlugin(ctx, "SchemaValidator", cfg.SchemaValidator, mgr.SchemaValidator); err != nil {
return err
}
if h.router, err = loadPlugin(ctx, "Router", cfg.Router, mgr.Router); err != nil {
return err
}
if h.publisher, err = loadPlugin(ctx, "Publisher", cfg.Publisher, mgr.Publisher); err != nil {
return err
}
if h.signer, err = loadPlugin(ctx, "Signer", cfg.Signer, mgr.Signer); err != nil {
return err
}
log.Debugf(ctx, "All required plugins successfully loaded for stdHandler")
return nil
}
// initSteps initializes and validates processing steps for the processor.
func (h *stdHandler) initSteps(ctx context.Context, mgr PluginManager, cfg *Config) error {
steps := make(map[string]definition.Step)
// Load plugin-based steps
for _, c := range cfg.Plugins.Steps {
step, err := mgr.Step(ctx, &c)
if err != nil {
return fmt.Errorf("failed to initialize plugin step %s: %w", c.ID, err)
}
steps[c.ID] = step
}
// Register processing steps
for _, step := range cfg.Steps {
var s definition.Step
var err error
switch step {
case "sign":
s, err = newSignStep(h.signer, h.km)
case "validateSign":
s, err = newValidateSignStep(h.signValidator, h.km)
case "validateSchema":
s, err = newValidateSchemaStep(h.schemaValidator)
case "addRoute":
s, err = newAddRouteStep(h.router)
default:
if customStep, exists := steps[step]; exists {
s = customStep
} else {
return fmt.Errorf("unrecognized step: %s", step)
}
}
if err != nil {
return err
}
h.steps = append(h.steps, s)
}
log.Infof(ctx, "Processor steps initialized: %v", cfg.Steps)
return nil
}

169
core/module/handler/step.go Normal file
View File

@@ -0,0 +1,169 @@
package handler
import (
"context"
"fmt"
"strings"
"time"
"github.com/beckn/beckn-onix/pkg/log"
"github.com/beckn/beckn-onix/pkg/model"
"github.com/beckn/beckn-onix/pkg/plugin/definition"
)
// signStep represents the signing step in the processing pipeline.
type signStep struct {
signer definition.Signer
km definition.KeyManager
}
// newSignStep initializes and returns a new signing step.
func newSignStep(signer definition.Signer, km definition.KeyManager) (definition.Step, error) {
if signer == nil {
return nil, fmt.Errorf("invalid config: Signer plugin not configured")
}
if km == nil {
return nil, fmt.Errorf("invalid config: KeyManager plugin not configured")
}
return &signStep{signer: signer, km: km}, nil
}
// Run executes the signing step.
func (s *signStep) Run(ctx *model.StepContext) error {
keyID, key, err := s.km.SigningPrivateKey(ctx, ctx.SubID)
if err != nil {
return fmt.Errorf("failed to get signing key: %w", err)
}
createdAt := time.Now().Unix()
validTill := time.Now().Add(5 * time.Minute).Unix()
sign, err := s.signer.Sign(ctx, ctx.Body, key, createdAt, validTill)
if err != nil {
return fmt.Errorf("failed to sign request: %w", err)
}
authHeader := s.generateAuthHeader(ctx.SubID, keyID, createdAt, validTill, sign)
header := model.AuthHeaderSubscriber
if ctx.Role == model.RoleGateway {
header = model.AuthHeaderGateway
}
ctx.Request.Header.Set(header, authHeader)
return nil
}
// generateAuthHeader constructs the authorization header for the signed request.
// It includes key ID, algorithm, creation time, expiration time, required headers, and signature.
func (s *signStep) generateAuthHeader(subID, keyID string, createdAt, validTill int64, signature string) string {
return fmt.Sprintf(
"Signature keyId=\"%s|%s|ed25519\",algorithm=\"ed25519\",created=\"%d\",expires=\"%d\",headers=\"(created) (expires) digest\",signature=\"%s\"",
subID, keyID, createdAt, validTill, signature,
)
}
// validateSignStep represents the signature validation step.
type validateSignStep struct {
validator definition.SignValidator
km definition.KeyManager
}
// newValidateSignStep initializes and returns a new validate sign step.
func newValidateSignStep(signValidator definition.SignValidator, km definition.KeyManager) (definition.Step, error) {
if signValidator == nil {
return nil, fmt.Errorf("invalid config: SignValidator plugin not configured")
}
if km == nil {
return nil, fmt.Errorf("invalid config: KeyManager plugin not configured")
}
return &validateSignStep{validator: signValidator, km: km}, nil
}
// Run executes the validation step.
func (s *validateSignStep) Run(ctx *model.StepContext) error {
unauthHeader := fmt.Sprintf("Signature realm=\"%s\",headers=\"(created) (expires) digest\"", ctx.SubID)
headerValue := ctx.Request.Header.Get(model.AuthHeaderGateway)
if len(headerValue) != 0 {
if err := s.validate(ctx, headerValue); err != nil {
ctx.RespHeader.Set(model.UnaAuthorizedHeaderGateway, unauthHeader)
return model.NewSignValidationErr(fmt.Errorf("failed to validate %s: %w", model.AuthHeaderGateway, err))
}
}
headerValue = ctx.Request.Header.Get(model.AuthHeaderSubscriber)
if len(headerValue) == 0 {
ctx.RespHeader.Set(model.UnaAuthorizedHeaderSubscriber, unauthHeader)
return model.NewSignValidationErr(fmt.Errorf("%s missing", model.UnaAuthorizedHeaderSubscriber))
}
if err := s.validate(ctx, headerValue); err != nil {
ctx.RespHeader.Set(model.UnaAuthorizedHeaderSubscriber, unauthHeader)
return model.NewSignValidationErr(fmt.Errorf("failed to validate %s: %w", model.AuthHeaderSubscriber, err))
}
return nil
}
// validate checks the validity of the provided signature header.
func (s *validateSignStep) validate(ctx *model.StepContext, value string) error {
headerParts := strings.Split(value, "|")
ids := strings.Split(headerParts[0], "\"")
if len(ids) < 2 || len(headerParts) < 3 {
return fmt.Errorf("malformed sign header")
}
subID := ids[1]
keyID := headerParts[1]
key, err := s.km.SigningPublicKey(ctx, subID, keyID)
if err != nil {
return fmt.Errorf("failed to get validation key: %w", err)
}
if err := s.validator.Validate(ctx, ctx.Body, value, key); err != nil {
return fmt.Errorf("sign validation failed: %w", err)
}
return nil
}
// validateSchemaStep represents the schema validation step.
type validateSchemaStep struct {
validator definition.SchemaValidator
}
// newValidateSchemaStep creates and returns the validateSchema step after validation.
func newValidateSchemaStep(schemaValidator definition.SchemaValidator) (definition.Step, error) {
if schemaValidator == nil {
return nil, fmt.Errorf("invalid config: SchemaValidator plugin not configured")
}
log.Debug(context.Background(), "adding schema validator")
return &validateSchemaStep{validator: schemaValidator}, nil
}
// Run executes the schema validation step.
func (s *validateSchemaStep) Run(ctx *model.StepContext) error {
if err := s.validator.Validate(ctx, ctx.Request.URL, ctx.Body); err != nil {
return fmt.Errorf("schema validation failed: %w", err)
}
return nil
}
// addRouteStep represents the route determination step.
type addRouteStep struct {
router definition.Router
}
// newAddRouteStep creates and returns the addRoute step after validation.
func newAddRouteStep(router definition.Router) (definition.Step, error) {
if router == nil {
return nil, fmt.Errorf("invalid config: Router plugin not configured")
}
return &addRouteStep{router: router}, nil
}
// Run executes the routing step.
func (s *addRouteStep) Run(ctx *model.StepContext) error {
route, err := s.router.Route(ctx, ctx.Request.URL, ctx.Body)
if err != nil {
return fmt.Errorf("failed to determine route: %w", err)
}
ctx.Route = &model.Route{
TargetType: route.TargetType,
PublisherID: route.PublisherID,
URL: route.URL,
}
return nil
}

82
core/module/module.go Normal file
View File

@@ -0,0 +1,82 @@
package module
import (
"context"
"fmt"
"net/http"
"github.com/beckn/beckn-onix/core/module/handler"
"github.com/beckn/beckn-onix/pkg/log"
"github.com/beckn/beckn-onix/pkg/model"
)
// Config represents the configuration for a module.
type Config struct {
Name string `yaml:"name"`
Path string `yaml:"path"`
Handler handler.Config
}
// Provider represents a function that initializes an HTTP handler using a PluginManager.
type Provider func(ctx context.Context, mgr handler.PluginManager, cfg *handler.Config) (http.Handler, error)
// handlerProviders maintains a mapping of handler types to their respective providers.
var handlerProviders = map[handler.Type]Provider{
handler.HandlerTypeStd: handler.NewStdHandler,
}
// Register initializes and registers handlers based on the provided configuration.
// It iterates over the module configurations, retrieves appropriate handler providers,
// and registers the handlers with the HTTP multiplexer.
func Register(ctx context.Context, mCfgs []Config, mux *http.ServeMux, mgr handler.PluginManager) error {
log.Debugf(ctx, "Registering modules with config: %#v", mCfgs)
// Iterate over the handlers in the configuration.
for _, c := range mCfgs {
rmp, ok := handlerProviders[c.Handler.Type]
if !ok {
return fmt.Errorf("invalid module : %s", c.Name)
}
h, err := rmp(ctx, mgr, &c.Handler)
if err != nil {
return fmt.Errorf("%s : %w", c.Name, err)
}
h, err = addMiddleware(ctx, mgr, h, &c.Handler)
if err != nil {
return fmt.Errorf("failed to add middleware: %w", err)
}
h = moduleCtxMiddleware(c.Name, h)
log.Debugf(ctx, "Registering handler %s, of type %s @ %s", c.Name, c.Handler.Type, c.Path)
mux.Handle(c.Path, h)
}
return nil
}
// addMiddleware applies middleware plugins to the provided handler in reverse order.
// It retrieves middleware instances from the plugin manager and chains them to the handler.
func addMiddleware(ctx context.Context, mgr handler.PluginManager, handler http.Handler, hCfg *handler.Config) (http.Handler, error) {
mws := hCfg.Plugins.Middleware
log.Debugf(ctx, "Applying %d middleware(s) to the handler", len(mws))
// Apply the middleware in reverse order.
for i := len(mws) - 1; i >= 0; i-- {
log.Debugf(ctx, "Loading middleware: %s", mws[i].ID)
mw, err := mgr.Middleware(ctx, &mws[i])
if err != nil {
log.Errorf(ctx, err, "Failed to load middleware %s: %v", mws[i].ID, err)
return nil, fmt.Errorf("failed to load middleware %s: %w", mws[i].ID, err)
}
// Apply the middleware to the handler.
handler = mw(handler)
log.Debugf(ctx, "Applied middleware: %s", mws[i].ID)
}
log.Debugf(ctx, "Middleware chain setup completed")
return handler, nil
}
func moduleCtxMiddleware(moduleName string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), model.ContextKeyModuleID, moduleName)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

175
core/module/module_test.go Normal file
View File

@@ -0,0 +1,175 @@
package module
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/beckn/beckn-onix/core/module/handler"
"github.com/beckn/beckn-onix/pkg/model"
"github.com/beckn/beckn-onix/pkg/plugin"
"github.com/beckn/beckn-onix/pkg/plugin/definition"
)
// mockPluginManager is a mock implementation of the PluginManager interface
// with support for dynamically setting behavior.
type mockPluginManager struct {
middlewareFunc func(ctx context.Context, cfg *plugin.Config) (func(http.Handler) http.Handler, error)
}
// Middleware returns a mock middleware function based on the provided configuration.
func (m *mockPluginManager) Middleware(ctx context.Context, cfg *plugin.Config) (func(http.Handler) http.Handler, error) {
return m.middlewareFunc(ctx, cfg)
}
// SignValidator returns a mock verifier implementation.
func (m *mockPluginManager) SignValidator(ctx context.Context, cfg *plugin.Config) (definition.SignValidator, error) {
return nil, nil
}
// Validator returns a mock schema validator implementation.
func (m *mockPluginManager) Validator(ctx context.Context, cfg *plugin.Config) (definition.SchemaValidator, error) {
return nil, nil
}
// Router returns a mock router implementation.
func (m *mockPluginManager) Router(ctx context.Context, cfg *plugin.Config) (definition.Router, error) {
return nil, nil
}
// Publisher returns a mock publisher implementation.
func (m *mockPluginManager) Publisher(ctx context.Context, cfg *plugin.Config) (definition.Publisher, error) {
return nil, nil
}
// Signer returns a mock signer implementation.
func (m *mockPluginManager) Signer(ctx context.Context, cfg *plugin.Config) (definition.Signer, error) {
return nil, nil
}
// Step returns a mock step implementation.
func (m *mockPluginManager) Step(ctx context.Context, cfg *plugin.Config) (definition.Step, error) {
return nil, nil
}
// Cache returns a mock cache implementation.
func (m *mockPluginManager) Cache(ctx context.Context, cfg *plugin.Config) (definition.Cache, error) {
return nil, nil
}
// KeyManager returns a mock key manager implementation.
func (m *mockPluginManager) KeyManager(ctx context.Context, cache definition.Cache, rLookup definition.RegistryLookup, cfg *plugin.Config) (definition.KeyManager, error) {
return nil, nil
}
// SchemaValidator returns a mock schema validator implementation.
func (m *mockPluginManager) SchemaValidator(ctx context.Context, cfg *plugin.Config) (definition.SchemaValidator, error) {
return nil, nil
}
// TestRegisterSuccess tests scenarios where the handler registration should succeed.
func TestRegisterSuccess(t *testing.T) {
mCfgs := []Config{
{
Name: "test-module",
Path: "/test",
Handler: handler.Config{
Type: handler.HandlerTypeStd,
Plugins: handler.PluginCfg{
Middleware: []plugin.Config{{ID: "mock-middleware"}},
},
},
},
}
mockManager := &mockPluginManager{
middlewareFunc: func(ctx context.Context, cfg *plugin.Config) (func(http.Handler) http.Handler, error) {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
})
}, nil
},
}
mux := http.NewServeMux()
err := Register(context.Background(), mCfgs, mux, mockManager)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
// Create a request and a response recorder
req := httptest.NewRequest(http.MethodGet, "/test", nil)
rec := httptest.NewRecorder()
// Create a handler that extracts context
var capturedModuleName any
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedModuleName = r.Context().Value(model.ContextKeyModuleID)
w.WriteHeader(http.StatusOK)
})
wrappedHandler := moduleCtxMiddleware("test-module", testHandler)
wrappedHandler.ServeHTTP(rec, req)
// Now verify if module name exists in context
if capturedModuleName != "test-module" {
t.Errorf("expected module_id in context to be 'test-module', got %v", capturedModuleName)
}
}
// TestRegisterFailure tests scenarios where the handler registration should fail.
func TestRegisterFailure(t *testing.T) {
tests := []struct {
name string
mCfgs []Config
mockManager *mockPluginManager
}{
{
name: "invalid handler type",
mCfgs: []Config{
{
Name: "invalid-module",
Path: "/invalid",
Handler: handler.Config{
Type: "invalid-type",
},
},
},
mockManager: &mockPluginManager{},
},
{
name: "middleware error",
mCfgs: []Config{
{
Name: "test-module",
Path: "/test",
Handler: handler.Config{
Type: handler.HandlerTypeStd,
Plugins: handler.PluginCfg{
Middleware: []plugin.Config{{ID: "mock-middleware"}},
},
},
},
},
mockManager: &mockPluginManager{
middlewareFunc: func(ctx context.Context, cfg *plugin.Config) (func(http.Handler) http.Handler, error) {
return nil, errors.New("middleware error")
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mux := http.NewServeMux()
err := Register(context.Background(), tt.mCfgs, mux, tt.mockManager)
if err == nil {
t.Errorf("expected an error but got nil")
}
})
}
}

34
go.mod
View File

@@ -1,10 +1,38 @@
module github.com/beckn/beckn-onix
go 1.23.4
go 1.24
require (
github.com/kr/pretty v0.3.1 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1
gopkg.in/yaml.v2 v2.4.0
golang.org/x/crypto v0.36.0
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
)
require golang.org/x/text v0.14.0 // indirect
require github.com/stretchr/testify v1.10.0
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
gopkg.in/yaml.v3 v3.0.1
)
require github.com/zenazn/pkcs7pad v0.0.0-20170308005700-253a5b1f0e03
require golang.org/x/text v0.23.0 // indirect
require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
golang.org/x/sys v0.31.0 // indirect
)
require (
github.com/hashicorp/go-retryablehttp v0.7.7
github.com/rs/zerolog v1.34.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v2 v2.4.0
)

60
go.sum
View File

@@ -1,10 +1,64 @@
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/zenazn/pkcs7pad v0.0.0-20170308005700-253a5b1f0e03 h1:m1h+vudopHsI67FPT9MOncyndWhTcdUoBtI1R1uajGY=
github.com/zenazn/pkcs7pad v0.0.0-20170308005700-253a5b1f0e03/go.mod h1:8sheVFH84v3PCyFY/O02mIgSQY9I6wMYPWsq7mDnEZY=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

295
pkg/log/log.go Normal file
View File

@@ -0,0 +1,295 @@
package log
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"time"
"github.com/beckn/beckn-onix/pkg/model"
"github.com/rs/zerolog"
"gopkg.in/natefinch/lumberjack.v2"
)
type level string
type destinationType string
type destination struct {
Type destinationType `yaml:"type"`
Config map[string]string `yaml:"config"`
}
// Destination types for logging output.
const (
Stdout destinationType = "stdout"
File destinationType = "file"
)
// Log levels define the severity of log messages.
const (
DebugLevel level = "debug"
InfoLevel level = "info"
WarnLevel level = "warn"
ErrorLevel level = "error"
FatalLevel level = "fatal"
PanicLevel level = "panic"
)
var logLevels = map[level]zerolog.Level{
DebugLevel: zerolog.DebugLevel,
InfoLevel: zerolog.InfoLevel,
WarnLevel: zerolog.WarnLevel,
ErrorLevel: zerolog.ErrorLevel,
FatalLevel: zerolog.FatalLevel,
PanicLevel: zerolog.PanicLevel,
}
// Config represents the configuration for logging.
type Config struct {
Level level `yaml:"level"`
Destinations []destination `yaml:"destinations"`
ContextKeys []model.ContextKey `yaml:"contextKeys"`
}
var (
logger zerolog.Logger
cfg Config
once sync.Once
)
// Logger instance and configuration.
var (
ErrInvalidLogLevel = errors.New("invalid log level")
ErrLogDestinationNil = errors.New("log Destinations cant be empty")
ErrMissingFilePath = errors.New("file path missing in destination config for file logging")
)
func (config *Config) validate() error {
if _, exists := logLevels[config.Level]; !exists {
return ErrInvalidLogLevel
}
if len(config.Destinations) == 0 {
return ErrLogDestinationNil
}
for _, dest := range config.Destinations {
switch dest.Type {
case Stdout:
case File:
if _, exists := dest.Config["path"]; !exists {
return ErrMissingFilePath
}
for _, key := range []string{"maxSize", "maxBackups", "maxAge"} {
if valStr, ok := dest.Config[key]; ok {
if _, err := strconv.Atoi(valStr); err != nil {
return fmt.Errorf("invalid %s: %w", key, err)
}
}
}
default:
return fmt.Errorf("invalid destination type '%s'", dest.Type)
}
}
return nil
}
var defaultConfig = Config{
Level: InfoLevel,
Destinations: []destination{
{Type: Stdout},
},
}
func init() {
logger, _ = getLogger(defaultConfig)
}
func getLogger(config Config) (zerolog.Logger, error) {
var newLogger zerolog.Logger
var writers []io.Writer
for _, dest := range config.Destinations {
switch dest.Type {
case Stdout:
writers = append(writers, os.Stdout)
case File:
filePath := dest.Config["path"]
dir := filepath.Dir(filePath)
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return newLogger, fmt.Errorf("failed to create log directory: %v", err)
}
lumberjackLogger := &lumberjack.Logger{
Filename: filePath,
Compress: false,
}
absPath, err := filepath.Abs(filePath)
if err != nil {
return newLogger, fmt.Errorf("failed to get absolute path: %v", err)
}
lumberjackLogger.Filename = absPath
setConfigValue := func(key string, target *int) {
if valStr, ok := dest.Config[key]; ok {
if val, err := strconv.Atoi(valStr); err == nil {
*target = val
}
}
}
setConfigValue("maxSize", &lumberjackLogger.MaxSize)
setConfigValue("maxBackups", &lumberjackLogger.MaxBackups)
setConfigValue("maxAge", &lumberjackLogger.MaxAge)
if compress, ok := dest.Config["compress"]; ok {
lumberjackLogger.Compress = compress == "true"
}
writers = append(writers, lumberjackLogger)
}
}
multiwriter := io.MultiWriter(writers...)
defer func() {
if closer, ok := multiwriter.(io.Closer); ok {
closer.Close()
}
}()
newLogger = zerolog.New(multiwriter).
Level(logLevels[config.Level]).
With().
Timestamp().
Logger()
cfg = config
return newLogger, nil
}
// InitLogger initializes the logger with the given configuration.
// It ensures that the logger is initialized only once using sync.Once.
func InitLogger(c Config) error {
var initErr error
once.Do(func() {
if initErr = c.validate(); initErr != nil {
return
}
logger, initErr = getLogger(c)
})
return initErr
}
// Debug logs a debug-level message with the provided context.
func Debug(ctx context.Context, msg string) {
logEvent(ctx, zerolog.DebugLevel, msg, nil)
}
// Debugf logs a formatted debug-level message with the provided context.
func Debugf(ctx context.Context, format string, v ...any) {
msg := fmt.Sprintf(format, v...)
logEvent(ctx, zerolog.DebugLevel, msg, nil)
}
// Info logs an info-level message with the provided context.
func Info(ctx context.Context, msg string) {
logEvent(ctx, zerolog.InfoLevel, msg, nil)
}
// Infof logs a formatted info-level message with the provided context.
func Infof(ctx context.Context, format string, v ...any) {
msg := fmt.Sprintf(format, v...)
logEvent(ctx, zerolog.InfoLevel, msg, nil)
}
// Warn logs a warning-level message with the provided context.
func Warn(ctx context.Context, msg string) {
logEvent(ctx, zerolog.WarnLevel, msg, nil)
}
// Warnf logs a formatted warning-level message with the provided context.
func Warnf(ctx context.Context, format string, v ...any) {
msg := fmt.Sprintf(format, v...)
logEvent(ctx, zerolog.WarnLevel, msg, nil)
}
// Error logs an error-level message along with an error object.
func Error(ctx context.Context, err error, msg string) {
logEvent(ctx, zerolog.ErrorLevel, msg, err)
}
// Errorf logs a formatted error-level message along with an error object.
func Errorf(ctx context.Context, err error, format string, v ...any) {
msg := fmt.Sprintf(format, v...)
logEvent(ctx, zerolog.ErrorLevel, msg, err)
}
// Fatal logs a fatal-level message along with an error object and exits the application.
func Fatal(ctx context.Context, err error, msg string) {
logEvent(ctx, zerolog.FatalLevel, msg, err)
}
// Fatalf logs a formatted fatal-level message along with an error object and exits the application.
func Fatalf(ctx context.Context, err error, format string, v ...any) {
msg := fmt.Sprintf(format, v...)
logEvent(ctx, zerolog.FatalLevel, msg, err)
}
// Panic logs a panic-level message along with an error object and panics.
func Panic(ctx context.Context, err error, msg string) {
logEvent(ctx, zerolog.PanicLevel, msg, err)
}
// Panicf logs a formatted panic-level message along with an error object and panics.
func Panicf(ctx context.Context, err error, format string, v ...any) {
msg := fmt.Sprintf(format, v...)
logEvent(ctx, zerolog.PanicLevel, msg, err)
}
// logEvent logs an event at the specified log level with an optional error message.
// It adds contextual information before logging the message.
func logEvent(ctx context.Context, level zerolog.Level, msg string, err error) {
event := logger.WithLevel(level)
if err != nil {
event = event.Err(err)
}
addCtx(ctx, event)
event.Msg(msg)
}
// Request logs details of an incoming HTTP request, including method, URL, body, and remote address.
func Request(ctx context.Context, r *http.Request, body []byte) {
event := logger.Info()
addCtx(ctx, event)
event.Str("method", r.Method).
Str("url", r.URL.String()).
Str("body", string(body)).
Str("remoteAddr", r.RemoteAddr).
Msg("HTTP Request")
}
// addCtx adds context values to the log event based on configured context keys.
func addCtx(ctx context.Context, event *zerolog.Event) {
for _, key := range cfg.ContextKeys {
val, ok := ctx.Value(key).(string)
if !ok {
continue
}
keyStr := string(key)
event.Any(keyStr, val)
}
}
// Response logs details of an outgoing HTTP response, including method, URL, status code, and response time.
func Response(ctx context.Context, r *http.Request, statusCode int, responseTime time.Duration) {
event := logger.Info()
addCtx(ctx, event)
event.Str("method", r.Method).
Str("url", r.URL.String()).
Int("statusCode", statusCode).
Dur("responseTime", responseTime).
Msg("HTTP Response")
}

673
pkg/log/log_test.go Normal file
View File

@@ -0,0 +1,673 @@
package log
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/beckn/beckn-onix/pkg/model"
)
type ctxKey any
var requestID ctxKey = "requestID"
const testLogFilePath = "./test_logs/test.log"
func TestMain(m *testing.M) {
// Create a single temporary directory for all tests
var err error
dir := filepath.Dir(testLogFilePath)
err = os.MkdirAll(dir, os.ModePerm)
if err != nil {
panic("failed to create test log directory: " + err.Error())
}
// Run all tests
code := m.Run()
// Cleanup: Remove the log directory after all tests finish
err = os.RemoveAll(dir)
if err != nil {
println("failed to clean up test log directory: ", err.Error())
}
// Exit with the appropriate exit code
os.Exit(code)
}
func setupLogger(t *testing.T, l level) string {
t.Helper()
// Create a temporary directory for logs.
config := Config{
Level: l,
Destinations: []destination{
{
Type: File,
Config: map[string]string{
"path": testLogFilePath,
"maxSize": "1",
"maxAge": "1",
"maxBackup": "1",
"compress": "false",
},
},
},
ContextKeys: []model.ContextKey{
model.ContextKeyTxnID,
model.ContextKeyMsgID,
model.ContextKeySubscriberID,
model.ContextKeyModuleID,
},
}
// Initialize logger with the given config
err := InitLogger(config)
if err != nil {
t.Fatalf("failed to initialize logger: %v", err)
}
return testLogFilePath
}
func readLogFile(t *testing.T, logPath string) []string {
t.Helper()
b, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("failed to read log file: %v", err)
}
return strings.Split(string(b), "\n")
}
func parseLogLine(t *testing.T, line string) map[string]interface{} {
t.Helper()
var logEntry map[string]interface{}
err := json.Unmarshal([]byte(line), &logEntry)
if err != nil {
t.Fatalf("Failed to parse log line: %v", err)
}
return logEntry
}
func TestDebug(t *testing.T) {
t.Helper()
logPath := setupLogger(t, DebugLevel)
ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345")
Debug(ctx, "Debug message")
lines := readLogFile(t, logPath)
if len(lines) == 0 {
t.Fatal("No logs were written.")
}
expected := map[string]interface{}{
"level": "debug",
"subscriber_id": "12345",
"message": "Debug message",
}
var found bool
for _, line := range lines {
logEntry := parseLogLine(t, line)
// Ignore 'time' while comparing
delete(logEntry, "time")
if reflect.DeepEqual(expected, logEntry) {
found = true
break
}
}
if !found {
t.Errorf("Expected Debug message, but it was not found in logs")
}
}
func TestInfo(t *testing.T) {
logPath := setupLogger(t, InfoLevel)
ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345")
Info(ctx, "Info message")
lines := readLogFile(t, logPath)
if len(lines) == 0 {
t.Fatal("No logs were written.")
}
expected := map[string]interface{}{
"level": "info",
"subscriber_id": "12345",
"message": "Info message",
}
var found bool
for _, line := range lines {
logEntry := parseLogLine(t, line)
// Ignore 'time' while comparing
delete(logEntry, "time")
if reflect.DeepEqual(expected, logEntry) {
found = true
break
}
}
if !found {
t.Errorf("expected Info message, but it was not found in logs")
}
}
func TestWarn(t *testing.T) {
logPath := setupLogger(t, WarnLevel)
ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345")
Warn(ctx, "Warning message")
lines := readLogFile(t, logPath)
if len(lines) == 0 {
t.Fatal("No logs were written.")
}
expected := map[string]interface{}{
"level": "warn",
"subscriber_id": "12345",
"message": "Warning message",
}
var found bool
for _, line := range lines {
logEntry := parseLogLine(t, line)
delete(logEntry, "time")
if reflect.DeepEqual(expected, logEntry) {
found = true
break
}
}
if !found {
t.Errorf("expected Warning message, but it was not found in logs")
}
}
func TestError(t *testing.T) {
logPath := setupLogger(t, ErrorLevel)
ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345")
Error(ctx, fmt.Errorf("test error"), "Error message")
lines := readLogFile(t, logPath)
if len(lines) == 0 {
t.Fatal("No logs were written.")
}
expected := map[string]interface{}{
"level": "error",
"subscriber_id": "12345",
"message": "Error message",
"error": "test error",
}
var found bool
for _, line := range lines {
logEntry := parseLogLine(t, line)
// Ignore 'time' while comparing
delete(logEntry, "time")
if reflect.DeepEqual(expected, logEntry) {
found = true
break
}
}
if !found {
t.Errorf("expected Error message, but it was not found in logs")
}
}
func TestRequest(t *testing.T) {
logPath := setupLogger(t, InfoLevel)
ctx := context.WithValue(context.Background(), requestID, "abc-123")
req, _ := http.NewRequest("POST", "/api/test", bytes.NewBuffer([]byte(`{"key":"value"}`)))
req.RemoteAddr = "127.0.0.1:8080"
Request(ctx, req, []byte(`{"key":"value"}`))
lines := readLogFile(t, logPath)
if len(lines) == 0 {
t.Fatal("No logs were written.")
}
var found bool
for _, line := range lines {
logEntry := parseLogLine(t, line)
if logEntry["message"] == "HTTP Request" || logEntry["method"] == "POST" {
found = true
break
}
}
if !found {
t.Errorf("expected formatted debug message, but it was not found in logs")
}
}
func TestResponse(t *testing.T) {
logPath := setupLogger(t, InfoLevel)
ctx := context.WithValue(context.Background(), requestID, "abc-123")
req, _ := http.NewRequest("GET", "/api/test", nil)
Response(ctx, req, 200, time.Millisecond*123)
lines := readLogFile(t, logPath)
if len(lines) == 0 {
t.Fatal("No logs were written.")
}
var found bool
for _, line := range lines {
logEntry := parseLogLine(t, line)
if logEntry["message"] == "HTTP Response" {
if logEntry["message"] == "HTTP Response" {
value, ok := logEntry["statusCode"]
if !ok {
t.Fatalf("Expected key 'statusCode' not found in log entry")
}
statusCode, ok := value.(float64)
if !ok {
t.Fatalf("Value for 'statusCode' is not a float64, found: %T", value)
}
if statusCode == 200 {
found = true
break
}
}
}
}
if !found {
t.Errorf("expected message, but it was not found in logs")
}
}
func TestFatal(t *testing.T) {
logPath := setupLogger(t, FatalLevel)
ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345")
Fatal(ctx, fmt.Errorf("fatal error"), "Fatal message")
lines := readLogFile(t, logPath)
if len(lines) == 0 {
t.Fatal("No logs were written.")
}
expected := map[string]interface{}{
"level": "fatal",
"subscriber_id": "12345",
"message": "Fatal message",
"error": "fatal error",
}
var found bool
for _, line := range lines {
logEntry := parseLogLine(t, line)
// Ignore 'time' while comparing
delete(logEntry, "time")
if reflect.DeepEqual(expected, logEntry) {
found = true
break
}
}
if !found {
t.Errorf("expected Fatal message, but it was not found in logs")
}
}
func TestPanic(t *testing.T) {
logPath := setupLogger(t, PanicLevel)
ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345")
Panic(ctx, fmt.Errorf("panic error"), "Panic message")
lines := readLogFile(t, logPath)
if len(lines) == 0 {
t.Fatal("No logs were written.")
}
expected := map[string]interface{}{
"level": "panic",
"subscriber_id": "12345",
"message": "Panic message",
"error": "panic error",
}
var found bool
for _, line := range lines {
logEntry := parseLogLine(t, line)
// Ignore 'time' while comparing
delete(logEntry, "time")
if reflect.DeepEqual(expected, logEntry) {
found = true
break
}
}
if !found {
t.Errorf("expected Panic message, but it was not found in logs")
}
}
func TestDebugf(t *testing.T) {
logPath := setupLogger(t, DebugLevel)
ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345")
Debugf(ctx, "Debugf message: %s", "test")
lines := readLogFile(t, logPath)
if len(lines) == 0 {
t.Fatal("No logs were written.")
}
expected := map[string]interface{}{
"level": "debug",
"subscriber_id": "12345",
"message": "Debugf message: test",
}
var found bool
for _, line := range lines {
logEntry := parseLogLine(t, line)
t.Log(line)
// Ignore 'time' while comparing
delete(logEntry, "time")
if reflect.DeepEqual(expected, logEntry) {
found = true
break
}
}
if !found {
t.Errorf("expected formatted debug message, but it was not found in logs")
}
}
func TestInfof(t *testing.T) {
logPath := setupLogger(t, InfoLevel)
ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345")
Infof(ctx, "Infof message: %s", "test")
lines := readLogFile(t, logPath)
if len(lines) == 0 {
t.Fatal("No logs were written.")
}
expected := map[string]interface{}{
"level": "info",
"subscriber_id": "12345",
"message": "Infof message: test",
}
var found bool
for _, line := range lines {
logEntry := parseLogLine(t, line)
// Ignore 'time' while comparing
delete(logEntry, "time")
if reflect.DeepEqual(expected, logEntry) {
found = true
break
}
}
if !found {
t.Errorf("expected Infof message, but it was not found in logs")
}
}
func TestWarnf(t *testing.T) {
logPath := setupLogger(t, WarnLevel)
ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345")
Warnf(ctx, "Warnf message: %s", "test")
lines := readLogFile(t, logPath)
if len(lines) == 0 {
t.Fatal("No logs were written.")
}
expected := map[string]interface{}{
"level": "warn",
"subscriber_id": "12345",
"message": "Warnf message: test",
}
var found bool
for _, line := range lines {
logEntry := parseLogLine(t, line)
// Ignore 'time' while comparing
delete(logEntry, "time")
if reflect.DeepEqual(expected, logEntry) {
found = true
break
}
}
if !found {
t.Errorf("expected Warnf message, but it was not found in logs")
}
}
func TestErrorf(t *testing.T) {
logPath := setupLogger(t, ErrorLevel)
ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345")
err := fmt.Errorf("error message")
Errorf(ctx, err, "Errorf message: %s", "test")
lines := readLogFile(t, logPath)
if len(lines) == 0 {
t.Fatal("No logs were written.")
}
expected := map[string]interface{}{
"level": "error",
"subscriber_id": "12345",
"message": "Errorf message: test",
"error": "error message",
}
var found bool
for _, line := range lines {
logEntry := parseLogLine(t, line)
// Ignore 'time' while comparing
delete(logEntry, "time")
if reflect.DeepEqual(expected, logEntry) {
found = true
break
}
}
if !found {
t.Errorf("expected Errorf message, but it was not found in logs")
}
}
func TestFatalf(t *testing.T) {
logPath := setupLogger(t, FatalLevel)
ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345")
err := fmt.Errorf("fatal error")
Fatalf(ctx, err, "Fatalf message: %s", "test")
lines := readLogFile(t, logPath)
if len(lines) == 0 {
t.Fatal("No logs were written.")
}
expected := map[string]interface{}{
"level": "fatal",
"subscriber_id": "12345",
"message": "Fatalf message: test",
"error": "fatal error",
}
var found bool
for _, line := range lines {
logEntry := parseLogLine(t, line)
// Ignore 'time' while comparing
delete(logEntry, "time")
if reflect.DeepEqual(expected, logEntry) {
found = true
break
}
}
if !found {
t.Errorf("expected Fatalf message, but it was not found in logs")
}
}
func TestPanicf(t *testing.T) {
logPath := setupLogger(t, PanicLevel)
ctx := context.WithValue(context.Background(), model.ContextKeySubscriberID, "12345")
err := fmt.Errorf("panic error")
Panicf(ctx, err, "Panicf message: %s", "test")
lines := readLogFile(t, logPath)
if len(lines) == 0 {
t.Fatal("No logs were written.")
}
expected := map[string]interface{}{
"level": "panic",
"subscriber_id": "12345",
"message": "Panicf message: test",
"error": "panic error",
}
var found bool
for _, line := range lines {
logEntry := parseLogLine(t, line)
// Ignore 'time' while comparing
delete(logEntry, "time")
if reflect.DeepEqual(expected, logEntry) {
found = true
break
}
}
if !found {
t.Errorf("expected Panicf message, but it was not found in logs")
}
}
func TestValidateConfig(t *testing.T) {
tests := []struct {
name string
config Config
wantErr error
}{
{
name: "Valid config with Stdout",
config: Config{
Level: InfoLevel,
Destinations: []destination{
{Type: Stdout},
},
},
wantErr: nil,
},
{
name: "Valid config with File destination and valid path",
config: Config{
Level: InfoLevel,
Destinations: []destination{
{
Type: File,
Config: map[string]string{
"path": "./logs/app.log",
"maxSize": "10",
"maxBackups": "5",
"maxAge": "7",
},
},
},
},
wantErr: nil,
},
{
name: "Error: Invalid log level",
config: Config{
Level: "invalid",
Destinations: []destination{
{Type: Stdout},
},
},
wantErr: ErrInvalidLogLevel,
},
{
name: "Error: No destinations provided",
config: Config{
Level: InfoLevel,
Destinations: []destination{},
},
wantErr: ErrLogDestinationNil,
},
{
name: "Error: Invalid destination type",
config: Config{
Level: InfoLevel,
Destinations: []destination{
{Type: "unknown"},
},
},
wantErr: fmt.Errorf("invalid destination type 'unknown'"),
},
{
name: "Error: Missing file path for file destination",
config: Config{
Level: InfoLevel,
Destinations: []destination{
{
Type: File,
Config: map[string]string{
"maxSize": "10",
},
},
},
},
wantErr: ErrMissingFilePath,
},
{
name: "Error: Invalid maxSize value in file destination",
config: Config{
Level: InfoLevel,
Destinations: []destination{
{
Type: File,
Config: map[string]string{
"path": "./logs/app.log",
"maxSize": "invalid",
},
},
},
},
wantErr: errors.New(`invalid maxSize: strconv.Atoi: parsing "invalid": invalid syntax`),
},
{
name: "Error: Invalid maxBackups value in file destination",
config: Config{
Level: InfoLevel,
Destinations: []destination{
{
Type: File,
Config: map[string]string{
"path": "./logs/app.log",
"maxBackups": "invalid",
},
},
},
},
wantErr: errors.New(`invalid maxBackups: strconv.Atoi: parsing "invalid": invalid syntax`),
},
{
name: "Error: Invalid maxAge value in file destination",
config: Config{
Level: InfoLevel,
Destinations: []destination{
{
Type: File,
Config: map[string]string{
"path": "./logs/app.log",
"maxAge": "invalid",
},
},
},
},
wantErr: errors.New(`invalid maxAge: strconv.Atoi: parsing "invalid": invalid syntax`),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.validate()
if (err == nil) != (tt.wantErr == nil) {
t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr)
}
if err != nil && tt.wantErr != nil && err.Error() != tt.wantErr.Error() {
t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

113
pkg/model/error.go Normal file
View File

@@ -0,0 +1,113 @@
package model
import (
"fmt"
"net/http"
"strings"
)
// Error represents a standard error response.
type Error struct {
Code string `json:"code"`
Paths string `json:"paths,omitempty"`
Message string `json:"message"`
}
// This implements the error interface for the Error struct.
func (e *Error) Error() string {
return fmt.Sprintf("Error: Code=%s, Path=%s, Message=%s", e.Code, e.Paths, e.Message)
}
// SchemaValidationErr occurs when schema validation errors are encountered.
type SchemaValidationErr struct {
Errors []Error
}
// This implements the error interface for SchemaValidationErr.
func (e *SchemaValidationErr) Error() string {
var errorMessages []string
for _, err := range e.Errors {
errorMessages = append(errorMessages, fmt.Sprintf("%s: %s", err.Paths, err.Message))
}
return strings.Join(errorMessages, "; ")
}
// BecknError converts the SchemaValidationErr to an instance of Error.
func (e *SchemaValidationErr) BecknError() *Error {
if len(e.Errors) == 0 {
return &Error{
Code: http.StatusText(http.StatusBadRequest),
Message: "Schema validation error.",
}
}
// Collect all error paths and messages
var paths []string
var messages []string
for _, err := range e.Errors {
if err.Paths != "" {
paths = append(paths, err.Paths)
}
messages = append(messages, err.Message)
}
return &Error{
Code: http.StatusText(http.StatusBadRequest),
Paths: strings.Join(paths, ";"),
Message: strings.Join(messages, "; "),
}
}
// SignValidationErr occurs when signature validation fails.
type SignValidationErr struct {
error
}
// NewSignValidationErr creates a new instance of SignValidationErr from an error.
func NewSignValidationErr(e error) *SignValidationErr {
return &SignValidationErr{e}
}
// BecknError converts the SignValidationErr to an instance of Error.
func (e *SignValidationErr) BecknError() *Error {
return &Error{
Code: http.StatusText(http.StatusUnauthorized),
Message: "Signature Validation Error: " + e.Error(),
}
}
// BadReqErr occurs when a bad request is encountered.
type BadReqErr struct {
error
}
// NewBadReqErr creates a new instance of BadReqErr from an error.
func NewBadReqErr(err error) *BadReqErr {
return &BadReqErr{err}
}
// BecknError converts the BadReqErr to an instance of Error.
func (e *BadReqErr) BecknError() *Error {
return &Error{
Code: http.StatusText(http.StatusBadRequest),
Message: "BAD Request: " + e.Error(),
}
}
// NotFoundErr occurs when a requested endpoint is not found.
type NotFoundErr struct {
error
}
// NewNotFoundErr creates a new instance of NotFoundErr from an error.
func NewNotFoundErr(err error) *NotFoundErr {
return &NotFoundErr{err}
}
// BecknError converts the NotFoundErr to an instance of Error.
func (e *NotFoundErr) BecknError() *Error {
return &Error{
Code: http.StatusText(http.StatusNotFound),
Message: "Endpoint not found: " + e.Error(),
}
}

252
pkg/model/error_test.go Normal file
View File

@@ -0,0 +1,252 @@
package model
import (
"errors"
"fmt"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v2"
)
// NewSignValidationErrf creates a new SignValidationErr with a formatted error message.
func NewSignValidationErrf(format string, a ...any) *SignValidationErr {
return &SignValidationErr{fmt.Errorf(format, a...)}
}
// NewNotFoundErrf creates a new NotFoundErr with a formatted error message.
func NewNotFoundErrf(format string, a ...any) *NotFoundErr {
return &NotFoundErr{fmt.Errorf(format, a...)}
}
// NewBadReqErrf creates a new BadReqErr with a formatted error message.
func NewBadReqErrf(format string, a ...any) *BadReqErr {
return &BadReqErr{fmt.Errorf(format, a...)}
}
func TestError_Error(t *testing.T) {
err := &Error{
Code: "404",
Paths: "/api/v1/user",
Message: "User not found",
}
expected := "Error: Code=404, Path=/api/v1/user, Message=User not found"
actual := err.Error()
if actual != expected {
t.Errorf("err.Error() = %s, want %s",
actual, expected)
}
}
func TestSchemaValidationErr_Error(t *testing.T) {
schemaErr := &SchemaValidationErr{
Errors: []Error{
{Paths: "/user", Message: "Field required"},
{Paths: "/email", Message: "Invalid format"},
},
}
expected := "/user: Field required; /email: Invalid format"
actual := schemaErr.Error()
if actual != expected {
t.Errorf("err.Error() = %s, want %s",
actual, expected)
}
}
func TestSchemaValidationErr_BecknError(t *testing.T) {
schemaErr := &SchemaValidationErr{
Errors: []Error{
{Paths: "/user", Message: "Field required"},
},
}
beErr := schemaErr.BecknError()
expected := "Bad Request"
if beErr.Code != expected {
t.Errorf("err.Error() = %s, want %s",
beErr.Code, expected)
}
}
func TestSignValidationErr_BecknError(t *testing.T) {
signErr := NewSignValidationErr(errors.New("signature failed"))
beErr := signErr.BecknError()
expectedMsg := "Signature Validation Error: signature failed"
if beErr.Message != expectedMsg {
t.Errorf("err.Error() = %s, want %s",
beErr.Message, expectedMsg)
}
}
func TestNewSignValidationErrf(t *testing.T) {
signErr := NewSignValidationErrf("error %s", "signature failed")
expected := "error signature failed"
if signErr.Error() != expected {
t.Errorf("err.Error() = %s, want %s",
signErr.Error(), expected)
}
}
func TestNewSignValidationErr(t *testing.T) {
err := errors.New("signature error")
signErr := NewSignValidationErr(err)
if signErr.Error() != err.Error() {
t.Errorf("err.Error() = %s, want %s", err.Error(),
signErr.Error())
}
}
func TestBadReqErr_BecknError(t *testing.T) {
badReqErr := NewBadReqErr(errors.New("invalid input"))
beErr := badReqErr.BecknError()
expectedMsg := "BAD Request: invalid input"
if beErr.Message != expectedMsg {
t.Errorf("err.Error() = %s, want %s",
beErr.Message, expectedMsg)
}
}
func TestNewBadReqErrf(t *testing.T) {
badReqErr := NewBadReqErrf("invalid field %s", "name")
expected := "invalid field name"
if badReqErr.Error() != expected {
t.Errorf("err.Error() = %s, want %s",
badReqErr, expected)
}
}
func TestNewBadReqErr(t *testing.T) {
err := errors.New("bad request")
badReqErr := NewBadReqErr(err)
if badReqErr.Error() != err.Error() {
t.Errorf("err.Error() = %s, want %s",
badReqErr.Error(), err.Error())
}
}
func TestNotFoundErr_BecknError(t *testing.T) {
notFoundErr := NewNotFoundErr(errors.New("resource not found"))
beErr := notFoundErr.BecknError()
expectedMsg := "Endpoint not found: resource not found"
if beErr.Message != expectedMsg {
t.Errorf("err.Error() = %s, want %s",
beErr.Message, expectedMsg)
}
}
func TestNewNotFoundErrf(t *testing.T) {
notFoundErr := NewNotFoundErrf("resource %s not found", "user")
expected := "resource user not found"
if notFoundErr.Error() != expected {
t.Errorf("err.Error() = %s, want %s",
notFoundErr.Error(), expected)
}
}
func TestNewNotFoundErr(t *testing.T) {
err := errors.New("not found")
notFoundErr := NewNotFoundErr(err)
if notFoundErr.Error() != err.Error() {
t.Errorf("err.Error() = %s, want %s",
notFoundErr.Error(), err.Error())
}
}
func TestRole_UnmarshalYAML_ValidRole(t *testing.T) {
var role Role
yamlData := []byte("bap")
err := yaml.Unmarshal(yamlData, &role)
assert.NoError(t, err) //TODO: should replace assert here
assert.Equal(t, RoleBAP, role)
}
func TestRole_UnmarshalYAML_InvalidRole(t *testing.T) {
var role Role
yamlData := []byte("invalid")
err := yaml.Unmarshal(yamlData, &role)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid Role")
}
func TestSchemaValidationErr_BecknError_NoErrors(t *testing.T) {
schemaValidationErr := &SchemaValidationErr{Errors: nil}
beErr := schemaValidationErr.BecknError()
expectedMsg := "Schema validation error."
expectedCode := http.StatusText(http.StatusBadRequest)
if beErr.Message != expectedMsg {
t.Errorf("beErr.Message = %s, want %s", beErr.Message, expectedMsg)
}
if beErr.Code != expectedCode {
t.Errorf("beErr.Code = %s, want %s", beErr.Code, expectedCode)
}
}
func TestParseContextKey_ValidKeys(t *testing.T) {
tests := []struct {
input string
expected ContextKey
}{
{"transaction_id", ContextKeyTxnID},
{"message_id", ContextKeyMsgID},
{"subscriber_id", ContextKeySubscriberID},
{"module_id", ContextKeyModuleID},
}
for _, tt := range tests {
key, err := ParseContextKey(tt.input)
if err != nil {
t.Errorf("unexpected error for input %s: %v", tt.input, err)
}
if key != tt.expected {
t.Errorf("expected %s, got %s", tt.expected, key)
}
}
}
func TestParseContextKey_InvalidKey(t *testing.T) {
_, err := ParseContextKey("invalid_key")
if err == nil {
t.Error("expected error for invalid context key, got nil")
}
}
func TestContextKey_UnmarshalYAML_Valid(t *testing.T) {
yamlData := []byte("message_id")
var key ContextKey
err := yaml.Unmarshal(yamlData, &key)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if key != ContextKeyMsgID {
t.Errorf("expected %s, got %s", ContextKeyMsgID, key)
}
}
func TestContextKey_UnmarshalYAML_Invalid(t *testing.T) {
yamlData := []byte("invalid_key")
var key ContextKey
err := yaml.Unmarshal(yamlData, &key)
if err == nil {
t.Error("expected error for invalid context key, got nil")
}
}

186
pkg/model/model.go Normal file
View File

@@ -0,0 +1,186 @@
package model
import (
"context"
"fmt"
"net/http"
"net/url"
"time"
)
// Subscriber represents a unique operational configuration of a trusted platform on a network.
type Subscriber struct {
SubscriberID string `json:"subscriber_id"`
URL string `json:"url" format:"uri"`
Type string `json:"type" enum:"BAP,BPP,BG"`
Domain string `json:"domain"`
}
// Subscription represents subscription details of a network participant.
type Subscription struct {
Subscriber `json:",inline"`
KeyID string `json:"key_id" format:"uuid"`
SigningPublicKey string `json:"signing_public_key"`
EncrPublicKey string `json:"encr_public_key"`
ValidFrom time.Time `json:"valid_from" format:"date-time"`
ValidUntil time.Time `json:"valid_until" format:"date-time"`
Status string `json:"status" enum:"INITIATED,UNDER_SUBSCRIPTION,SUBSCRIBED,EXPIRED,UNSUBSCRIBED,INVALID_SSL"`
Created time.Time `json:"created" format:"date-time"`
Updated time.Time `json:"updated" format:"date-time"`
Nonce string
}
// Authorization-related constants for headers.
const (
AuthHeaderSubscriber string = "Authorization"
AuthHeaderGateway string = "X-Gateway-Authorization"
UnaAuthorizedHeaderSubscriber string = "WWW-Authenticate"
UnaAuthorizedHeaderGateway string = "Proxy-Authenticate"
)
// ContextKey is a custom type used as a key for storing and retrieving values in a context.
type ContextKey string
const (
// ContextKeyTxnID is the context key used to store and retrieve the transaction ID in a request context.
ContextKeyTxnID ContextKey = "transaction_id"
// ContextKeyMsgID is the context key used to store and retrieve the message ID in a request context.
ContextKeyMsgID ContextKey = "message_id"
// ContextKeySubscriberID is the context key used to store and retrieve the subscriber ID in a request context.
ContextKeySubscriberID ContextKey = "subscriber_id"
// ContextKeyModuleID is the context key for storing and retrieving the model ID from a request context.
ContextKeyModuleID ContextKey = "module_id"
)
var contextKeys = map[string]ContextKey{
"transaction_id": ContextKeyTxnID,
"message_id": ContextKeyMsgID,
"subscriber_id": ContextKeySubscriberID,
"module_id": ContextKeyModuleID,
}
// ParseContextKey converts a string into a valid ContextKey.
func ParseContextKey(v string) (ContextKey, error) {
key, ok := contextKeys[v]
if !ok {
return "", fmt.Errorf("invalid context key: %s", v)
}
return key, nil
}
// UnmarshalYAML ensures that only known context keys are accepted during YAML unmarshalling.
func (k *ContextKey) UnmarshalYAML(unmarshal func(interface{}) error) error {
var keyStr string
if err := unmarshal(&keyStr); err != nil {
return err
}
parsedKey, err := ParseContextKey(keyStr)
if err != nil {
return err
}
*k = parsedKey
return nil
}
// Role defines the type of participant in the network.
type Role string
const (
// RoleBAP represents a Buyer App Participant (BAP) in the network.
RoleBAP Role = "bap"
// RoleBPP represents a Buyer Platform Participant (BPP) in the network.
RoleBPP Role = "bpp"
// RoleGateway represents a Gateway that facilitates communication in the network.
RoleGateway Role = "gateway"
// RoleRegistery represents the Registry that maintains network participant details.
RoleRegistery Role = "registery"
)
var validRoles = map[Role]bool{
RoleBAP: true,
RoleBPP: true,
RoleGateway: true,
RoleRegistery: true,
}
// UnmarshalYAML implements custom YAML unmarshalling for Role to ensure only valid values are accepted.
func (r *Role) UnmarshalYAML(unmarshal func(interface{}) error) error {
var roleName string
if err := unmarshal(&roleName); err != nil {
return err
}
role := Role(roleName)
if !validRoles[role] {
return fmt.Errorf("invalid Role: %s", roleName)
}
*r = role
return nil
}
// Route represents a network route for message processing.
type Route struct {
TargetType string // "url" or "publisher"
PublisherID string // For message queues
URL *url.URL // For API calls
}
// Keyset represents a collection of cryptographic keys used for signing and encryption.
type Keyset struct {
UniqueKeyID string // UniqueKeyID is the identifier for the key pair.
SigningPrivate string // SigningPrivate is the private key used for signing operations.
SigningPublic string // SigningPublic is the public key corresponding to the signing private key.
EncrPrivate string // EncrPrivate is the private key used for encryption operations.
EncrPublic string // EncrPublic is the public key corresponding to the encryption private key.
}
// StepContext holds context information for a request processing step.
type StepContext struct {
context.Context
Request *http.Request
Body []byte
Route *Route
SubID string
Role Role
RespHeader http.Header
}
// WithContext updates the existing StepContext with a new context.
func (ctx *StepContext) WithContext(newCtx context.Context) {
ctx.Context = newCtx
}
// Status represents the acknowledgment status in a response.
type Status string
const (
// StatusACK indicates a successful acknowledgment.
StatusACK Status = "ACK"
// StatusNACK indicates a negative acknowledgment or failure.
StatusNACK Status = "NACK"
)
// Ack represents an acknowledgment response.
type Ack struct {
// Status holds the acknowledgment status (ACK/NACK).
Status Status `json:"status"`
}
// Message represents the structure of a response message.
type Message struct {
// Ack contains the acknowledgment status.
Ack Ack `json:"ack"`
// Error holds error details, if any, in the response.
Error *Error `json:"error,omitempty"`
}
// Response represents the main response structure.
type Response struct {
Message Message `json:"message"`
}

21
pkg/plugin/config.go Normal file
View File

@@ -0,0 +1,21 @@
package plugin
type PublisherCfg struct {
ID string `yaml:"id"`
Config map[string]string `yaml:"config"`
}
type ValidatorCfg struct {
ID string `yaml:"id"`
Config map[string]string `yaml:"config"`
}
type Config struct {
ID string `yaml:"id"`
Config map[string]string `yaml:"config"`
}
type ManagerConfig struct {
Root string `yaml:"root"`
RemoteRoot string `yaml:"remoteRoot"`
}

View File

@@ -0,0 +1,27 @@
package definition
import (
"context"
"time"
)
// Cache defines the general cache interface for caching plugins.
type Cache interface {
// Get retrieves a value from the cache based on the given key.
Get(ctx context.Context, key string) (string, error)
// Set stores a value in the cache with the given key and TTL (time-to-live) in seconds.
Set(ctx context.Context, key, value string, ttl time.Duration) error
// Delete removes a value from the cache based on the given key.
Delete(ctx context.Context, key string) error
// Clear removes all values from the cache.
Clear(ctx context.Context) error
}
// CacheProvider interface defines the contract for managing cache instances.
type CacheProvider interface {
// New initializes a new cache instance with the given configuration.
New(ctx context.Context, config map[string]string) (Cache, func() error, error)
}

View File

@@ -0,0 +1,15 @@
package definition
import "context"
// Decrypter defines the methods for decryption.
type Decrypter interface {
// Decrypt decrypts the given body using the provided privateKeyBase64 and publicKeyBase64.
Decrypt(ctx context.Context, encryptedData string, privateKeyBase64, publicKeyBase64 string) (string, error)
}
// DecrypterProvider initializes a new decrypter instance with the given config.
type DecrypterProvider interface {
// New creates a new decrypter instance based on the provided config.
New(ctx context.Context, config map[string]string) (Decrypter, func() error, error)
}

View File

@@ -0,0 +1,15 @@
package definition
import "context"
// Encrypter defines the methods for encryption.
type Encrypter interface {
// Encrypt encrypts the given body using the provided privateKeyBase64 and publicKeyBase64.
Encrypt(ctx context.Context, data string, privateKeyBase64, publicKeyBase64 string) (string, error)
}
// EncrypterProvider initializes a new encrypter instance with the given config.
type EncrypterProvider interface {
// New creates a new encrypter instance based on the provided config.
New(ctx context.Context, config map[string]string) (Encrypter, func() error, error)
}

View File

@@ -0,0 +1,23 @@
package definition
import (
"context"
"github.com/beckn/beckn-onix/pkg/model"
)
// KeyManager defines the interface for key management operations/methods.
type KeyManager interface {
GenerateKeyPairs() (*model.Keyset, error)
StorePrivateKeys(ctx context.Context, keyID string, keys *model.Keyset) error
SigningPrivateKey(ctx context.Context, keyID string) (string, string, error)
EncrPrivateKey(ctx context.Context, keyID string) (string, string, error)
SigningPublicKey(ctx context.Context, subscriberID, uniqueKeyID string) (string, error)
EncrPublicKey(ctx context.Context, subscriberID, uniqueKeyID string) (string, error)
DeletePrivateKeys(ctx context.Context, keyID string) error
}
// KeyManagerProvider initializes a new signer instance.
type KeyManagerProvider interface {
New(context.Context, Cache, RegistryLookup, map[string]string) (KeyManager, func() error, error)
}

View File

@@ -0,0 +1,10 @@
package definition
import (
"context"
"net/http"
)
type MiddlewareProvider interface {
New(ctx context.Context, cfg map[string]string) (func(http.Handler) http.Handler, error)
}

View File

@@ -0,0 +1,14 @@
package definition
import "context"
// Publisher defines the general publisher interface for messaging plugins.
type Publisher interface {
// Publish sends a message (as a byte slice) using the underlying messaging system.
Publish(context.Context, string, []byte) error
}
type PublisherProvider interface {
// New initializes a new publisher instance with the given configuration.
New(ctx context.Context, config map[string]string) (Publisher, func() error, error)
}

View File

@@ -0,0 +1,11 @@
package definition
import (
"context"
"github.com/beckn/beckn-onix/pkg/model"
)
type RegistryLookup interface {
Lookup(ctx context.Context, req *model.Subscription) ([]model.Subscription, error)
}

View File

@@ -0,0 +1,19 @@
package definition
import (
"context"
"net/url"
"github.com/beckn/beckn-onix/pkg/model"
)
// RouterProvider initializes the a new Router instance with the given config.
type RouterProvider interface {
New(ctx context.Context, config map[string]string) (Router, func() error, error)
}
// Router defines the interface for routing requests.
type Router interface {
// Route determines the routing destination based on the request context.
Route(ctx context.Context, url *url.URL, body []byte) (*model.Route, error)
}

View File

@@ -0,0 +1,16 @@
package definition
import (
"context"
"net/url"
)
// SchemaValidator interface for schema validation.
type SchemaValidator interface {
Validate(ctx context.Context, url *url.URL, payload []byte) error
}
// SchemaValidatorProvider interface for creating validators.
type SchemaValidatorProvider interface {
New(ctx context.Context, config map[string]string) (SchemaValidator, func() error, error)
}

View File

@@ -0,0 +1,17 @@
package definition
import "context"
// Signer defines the method for signing.
type Signer interface {
// Sign generates a signature for the given body and privateKeyBase64.
// The signature is created with the given timestamps: createdAt (signature creation time)
// and expiresAt (signature expiration time).
Sign(ctx context.Context, body []byte, privateKeyBase64 string, createdAt, expiresAt int64) (string, error)
}
// SignerProvider initializes a new signer instance with the given config.
type SignerProvider interface {
// New creates a new signer instance based on the provided config.
New(ctx context.Context, config map[string]string) (Signer, func() error, error)
}

View File

@@ -0,0 +1,15 @@
package definition
import "context"
// SignValidator defines the method for verifying signatures.
type SignValidator interface {
// Validate checks the validity of the signature for the given body.
Validate(ctx context.Context, body []byte, header string, publicKeyBase64 string) error
}
// SignValidatorProvider initializes a new Verifier instance with the given config.
type SignValidatorProvider interface {
// New creates a new Verifier instance based on the provided config.
New(ctx context.Context, config map[string]string) (SignValidator, func() error, error)
}

View File

@@ -0,0 +1,15 @@
package definition
import (
"context"
"github.com/beckn/beckn-onix/pkg/model"
)
type Step interface {
Run(ctx *model.StepContext) error
}
type StepProvider interface {
New(context.Context, map[string]string) (Step, func(), error)
}

View File

@@ -0,0 +1,19 @@
package main
import (
"context"
"github.com/beckn/beckn-onix/pkg/plugin/definition"
decrypter "github.com/beckn/beckn-onix/pkg/plugin/implementation/decrypter"
)
// decrypterProvider implements the definition.decrypterProvider interface.
type decrypterProvider struct{}
// New creates a new Decrypter instance using the provided configuration.
func (dp decrypterProvider) New(ctx context.Context, config map[string]string) (definition.Decrypter, func() error, error) {
return decrypter.New(ctx)
}
// Provider is the exported symbol that the plugin manager will look for.
var Provider = decrypterProvider{}

View File

@@ -0,0 +1,49 @@
package main
import (
"context"
"testing"
)
func TestDecrypterProviderSuccess(t *testing.T) {
tests := []struct {
name string
ctx context.Context
config map[string]string
}{
{
name: "Valid context with empty config",
ctx: context.Background(),
config: map[string]string{},
},
{
name: "Valid context with non-empty config",
ctx: context.Background(),
config: map[string]string{"key": "value"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
provider := decrypterProvider{}
decrypter, cleanup, err := provider.New(tt.ctx, tt.config)
// Check error.
if err != nil {
t.Errorf("New() error = %v, want no error", err)
}
// Check decrypter.
if decrypter == nil {
t.Error("New() decrypter is nil, want non-nil")
}
// Test cleanup function if it exists.
if cleanup != nil {
if err := cleanup(); err != nil {
t.Errorf("cleanup() error = %v", err)
}
}
})
}
}

View File

@@ -0,0 +1,87 @@
package decryption
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"encoding/base64"
"fmt"
"github.com/zenazn/pkcs7pad"
"github.com/beckn/beckn-onix/pkg/model"
)
// decrypter implements the Decrypter interface and handles the decryption process.
type decrypter struct {
}
// New creates a new decrypter instance with the given configuration.
func New(ctx context.Context) (*decrypter, func() error, error) {
return &decrypter{}, nil, nil
}
// Decrypt decrypts the given encryptedData using the provided privateKeyBase64 and publicKeyBase64.
func (d *decrypter) Decrypt(ctx context.Context, encryptedData, privateKeyBase64, publicKeyBase64 string) (string, error) {
privateKeyBytes, err := base64.StdEncoding.DecodeString(privateKeyBase64)
if err != nil {
return "", model.NewBadReqErr(fmt.Errorf("invalid private key: %w", err))
}
publicKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyBase64)
if err != nil {
return "", model.NewBadReqErr(fmt.Errorf("invalid public key: %w", err))
}
// Decode the Base64 encoded encrypted data.
messageByte, err := base64.StdEncoding.DecodeString(encryptedData)
if err != nil {
return "", model.NewBadReqErr(fmt.Errorf("failed to decode encrypted data: %w", err))
}
aesCipher, err := createAESCipher(privateKeyBytes, publicKeyBytes)
if err != nil {
return "", fmt.Errorf("failed to create AES cipher: %w", err)
}
blocksize := aesCipher.BlockSize()
if len(messageByte)%blocksize != 0 {
return "", fmt.Errorf("ciphertext is not a multiple of the blocksize")
}
for i := 0; i < len(messageByte); i += aesCipher.BlockSize() {
executionSlice := messageByte[i : i+aesCipher.BlockSize()]
aesCipher.Decrypt(executionSlice, executionSlice)
}
messageByte, err = pkcs7pad.Unpad(messageByte)
if err != nil {
return "", fmt.Errorf("failed to unpad data: %w", err)
}
return string(messageByte), nil
}
func createAESCipher(privateKey, publicKey []byte) (cipher.Block, error) {
x25519Curve := ecdh.X25519()
x25519PrivateKey, err := x25519Curve.NewPrivateKey(privateKey)
if err != nil {
return nil, fmt.Errorf("failed to create private key: %w", err)
}
x25519PublicKey, err := x25519Curve.NewPublicKey(publicKey)
if err != nil {
return nil, fmt.Errorf("failed to create public key: %w", err)
}
sharedSecret, err := x25519PrivateKey.ECDH(x25519PublicKey)
if err != nil {
return nil, fmt.Errorf("failed to derive shared secret: %w", err)
}
aesCipher, err := aes.NewCipher(sharedSecret)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}
return aesCipher, nil
}

View File

@@ -0,0 +1,251 @@
package decryption
import (
"context"
"crypto/aes"
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"strings"
"testing"
"github.com/zenazn/pkcs7pad"
)
// Helper function to generate valid test keys.
func generateTestKeys(t *testing.T) (privateKeyB64, publicKeyB64 string) {
curve := ecdh.X25519()
privateKey, err := curve.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("Failed to generate private key: %v", err)
}
publicKey := privateKey.PublicKey()
privateKeyB64 = base64.StdEncoding.EncodeToString(privateKey.Bytes())
publicKeyB64 = base64.StdEncoding.EncodeToString(publicKey.Bytes())
return privateKeyB64, publicKeyB64
}
// Helper function to encrypt test data.
func encryptTestData(t *testing.T, data []byte, privateKeyBase64, publicKeyBase64 string) string {
privateKeyBytes, err := base64.StdEncoding.DecodeString(privateKeyBase64)
if err != nil {
t.Fatalf("Invalid private key: %v", err)
}
publicKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyBase64)
if err != nil {
t.Fatalf("Invalid public key: %v", err)
}
x25519Curve := ecdh.X25519()
x25519PrivateKey, err := x25519Curve.NewPrivateKey(privateKeyBytes)
if err != nil {
t.Fatalf("Failed to create private key: %v", err)
}
x25519PublicKey, err := x25519Curve.NewPublicKey(publicKeyBytes)
if err != nil {
t.Fatalf("Failed to create public key: %v", err)
}
// Generate shared secret for encryption.
sharedSecret, err := x25519PrivateKey.ECDH(x25519PublicKey)
if err != nil {
t.Fatalf("Failed to create shared secret: %v", err)
}
// Create AES cipher.
block, err := aes.NewCipher(sharedSecret)
if err != nil {
t.Fatalf("Failed to create AES cipher: %v", err)
}
// Pad the data.
paddedData := pkcs7pad.Pad(data, block.BlockSize())
// Encrypt the data.
ciphertext := make([]byte, len(paddedData))
for i := 0; i < len(paddedData); i += block.BlockSize() {
block.Encrypt(ciphertext[i:i+block.BlockSize()], paddedData[i:i+block.BlockSize()])
}
return base64.StdEncoding.EncodeToString(ciphertext)
}
// TestDecrypterSuccess tests successful decryption scenarios.
func TestDecrypterSuccess(t *testing.T) {
senderPrivateKeyB64, senderPublicKeyB64 := generateTestKeys(t)
receiverPrivateKeyB64, receiverPublicKeyB64 := generateTestKeys(t)
tests := []struct {
name string
data []byte
}{
{
name: "Valid decryption with small data",
data: []byte("test"),
},
{
name: "Valid decryption with medium data",
data: []byte("medium length test data that spans multiple blocks"),
},
{
name: "Valid decryption with empty data",
data: []byte{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Encrypt the test data.
encryptedData := encryptTestData(t, tt.data, senderPrivateKeyB64, receiverPublicKeyB64)
decrypter, _, err := New(context.Background())
if err != nil {
t.Fatalf("Failed to create decrypter: %v", err)
}
result, err := decrypter.Decrypt(context.Background(), encryptedData, receiverPrivateKeyB64, senderPublicKeyB64)
if err != nil {
t.Errorf("Decrypt() error = %v", err)
}
if err == nil {
if result != string(tt.data) {
t.Errorf("Decrypt() = %v, want %v", result, string(tt.data))
}
}
})
}
}
// TestDecrypterFailure tests various failure scenarios.
func TestDecrypterFailure(t *testing.T) {
_, senderPublicKeyB64 := generateTestKeys(t)
receiverPrivateKeyB64, _ := generateTestKeys(t)
tests := []struct {
name string
encryptedData string
privateKey string
publicKey string
expectedErr string
}{
{
name: "Invalid private key format",
encryptedData: base64.StdEncoding.EncodeToString(make([]byte, 32)),
privateKey: "invalid-base64!@#$",
publicKey: senderPublicKeyB64,
expectedErr: "invalid private key",
},
{
name: "Invalid public key format",
encryptedData: base64.StdEncoding.EncodeToString(make([]byte, 32)),
privateKey: receiverPrivateKeyB64,
publicKey: "invalid-base64!@#$",
expectedErr: "invalid public key",
},
{
name: "Invalid encrypted data format",
encryptedData: "invalid-base64!@#$",
privateKey: receiverPrivateKeyB64,
publicKey: senderPublicKeyB64,
expectedErr: "failed to decode encrypted data",
},
{
name: "Empty private key",
encryptedData: base64.StdEncoding.EncodeToString(make([]byte, 32)),
privateKey: "",
publicKey: senderPublicKeyB64,
expectedErr: "invalid private key",
},
{
name: "Empty public key",
encryptedData: base64.StdEncoding.EncodeToString(make([]byte, 32)),
privateKey: receiverPrivateKeyB64,
publicKey: "",
expectedErr: "invalid public key",
},
{
name: "Invalid base64 data",
encryptedData: "=invalid-base64", // Invalid encrypted data.
privateKey: receiverPrivateKeyB64,
publicKey: senderPublicKeyB64,
expectedErr: "failed to decode encrypted data",
},
{
name: "Invalid private key size",
encryptedData: base64.StdEncoding.EncodeToString(make([]byte, 32)),
privateKey: base64.StdEncoding.EncodeToString([]byte("short")),
publicKey: senderPublicKeyB64,
expectedErr: "failed to create private key",
},
{
name: "Invalid public key size",
encryptedData: base64.StdEncoding.EncodeToString(make([]byte, 32)),
privateKey: receiverPrivateKeyB64,
publicKey: base64.StdEncoding.EncodeToString([]byte("short")),
expectedErr: "failed to create public key",
},
{
name: "Invalid block size",
encryptedData: base64.StdEncoding.EncodeToString([]byte("not-block-size")),
privateKey: receiverPrivateKeyB64,
publicKey: senderPublicKeyB64,
expectedErr: "ciphertext is not a multiple of the blocksize",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
decrypter, _, err := New(context.Background())
if err != nil {
t.Fatalf("Failed to create decrypter: %v", err)
}
_, err = decrypter.Decrypt(context.Background(), tt.encryptedData, tt.privateKey, tt.publicKey)
if err == nil {
t.Error("Expected error but got none")
}
if err != nil {
if !strings.Contains(err.Error(), tt.expectedErr) {
t.Errorf("Expected error containing %q, got %q", tt.expectedErr, err.Error())
}
}
})
}
}
// TestNewDecrypter tests the creation of new Decrypter instances.
func TestNewDecrypter(t *testing.T) {
tests := []struct {
name string
ctx context.Context
}{
{
name: "Valid context",
ctx: context.Background(),
},
{
name: "Nil context",
ctx: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
decrypter, _, err := New(tt.ctx)
if err != nil {
t.Errorf("New() error = %v", err)
}
if err == nil {
if decrypter == nil {
t.Error("Expected non-nil decrypter")
}
}
})
}
}

View File

@@ -0,0 +1,18 @@
package main
import (
"context"
"github.com/beckn/beckn-onix/pkg/plugin/definition"
"github.com/beckn/beckn-onix/pkg/plugin/implementation/encrypter"
)
// encrypterProvider implements the definition.encrypterProvider interface.
type encrypterProvider struct{}
func (ep encrypterProvider) New(ctx context.Context, config map[string]string) (definition.Encrypter, func() error, error) {
return encrypter.New(ctx)
}
// Provider is the exported symbol that the plugin manager will look for.
var Provider = encrypterProvider{}

View File

@@ -0,0 +1,49 @@
package main
import (
"context"
"testing"
)
func TestEncrypterProviderSuccess(t *testing.T) {
tests := []struct {
name string
ctx context.Context
config map[string]string
}{
{
name: "Valid empty config",
ctx: context.Background(),
config: map[string]string{},
},
{
name: "Valid config with algorithm",
ctx: context.Background(),
config: map[string]string{
"algorithm": "AES",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create provider and encrypter.
provider := encrypterProvider{}
encrypter, cleanup, err := provider.New(tt.ctx, tt.config)
if err != nil {
t.Fatalf("EncrypterProvider.New() error = %v", err)
}
if encrypter == nil {
t.Fatal("EncrypterProvider.New() returned nil encrypter")
}
defer func() {
if cleanup != nil {
if err := cleanup(); err != nil {
t.Errorf("Cleanup() error = %v", err)
}
}
}()
})
}
}

View File

@@ -0,0 +1,71 @@
package encrypter
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"encoding/base64"
"fmt"
"github.com/beckn/beckn-onix/pkg/model"
"github.com/zenazn/pkcs7pad"
)
// encrypter implements the Encrypter interface and handles the encryption process.
type encrypter struct {
}
// New creates a new encrypter instance with the given configuration.
func New(ctx context.Context) (*encrypter, func() error, error) {
return &encrypter{}, nil, nil
}
func (e *encrypter) Encrypt(ctx context.Context, data string, privateKeyBase64, publicKeyBase64 string) (string, error) {
privateKeyBytes, err := base64.StdEncoding.DecodeString(privateKeyBase64)
if err != nil {
return "", model.NewBadReqErr(fmt.Errorf("invalid private key: %w", err))
}
publicKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyBase64)
if err != nil {
return "", model.NewBadReqErr(fmt.Errorf("invalid public key: %w", err))
}
// Convert the input string to a byte slice.
dataByte := []byte(data)
aesCipher, err := createAESCipher(privateKeyBytes, publicKeyBytes)
if err != nil {
return "", fmt.Errorf("failed to create AES cipher: %w", err)
}
dataByte = pkcs7pad.Pad(dataByte, aesCipher.BlockSize())
for i := 0; i < len(dataByte); i += aesCipher.BlockSize() {
aesCipher.Encrypt(dataByte[i:i+aesCipher.BlockSize()], dataByte[i:i+aesCipher.BlockSize()])
}
return base64.StdEncoding.EncodeToString(dataByte), nil
}
func createAESCipher(privateKey, publicKey []byte) (cipher.Block, error) {
x25519Curve := ecdh.X25519()
x25519PrivateKey, err := x25519Curve.NewPrivateKey(privateKey)
if err != nil {
return nil, model.NewBadReqErr(fmt.Errorf("failed to create private key: %w", err))
}
x25519PublicKey, err := x25519Curve.NewPublicKey(publicKey)
if err != nil {
return nil, model.NewBadReqErr(fmt.Errorf("failed to create public key: %w", err))
}
sharedSecret, err := x25519PrivateKey.ECDH(x25519PublicKey)
if err != nil {
return nil, fmt.Errorf("failed to derive shared secret: %w", err)
}
aesCipher, err := aes.NewCipher(sharedSecret)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}
return aesCipher, nil
}

View File

@@ -0,0 +1,183 @@
package encrypter
import (
"context"
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"strings"
"testing"
)
// Helper function to generate a test X25519 key pair.
func generateTestKeyPair(t *testing.T) (string, string) {
curve := ecdh.X25519()
privateKey, err := curve.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("Failed to generate private key: %v", err)
}
publicKeyBytes := privateKey.PublicKey().Bytes()
// Encode public and private key to base64.
publicKeyBase64 := base64.StdEncoding.EncodeToString(publicKeyBytes)
privateKeyBase64 := base64.StdEncoding.EncodeToString(privateKey.Bytes())
return publicKeyBase64, privateKeyBase64
}
// TestEncryptSuccess tests successful encryption scenarios.
func TestEncryptSuccess(t *testing.T) {
_, privateKey := generateTestKeyPair(t)
peerpublicKey, _ := generateTestKeyPair(t)
tests := []struct {
name string
data string
pubKey string
privKey string
}{
{
name: "Valid short message",
data: "Hello, World!",
pubKey: peerpublicKey,
privKey: privateKey,
},
{
name: "Valid JSON message",
data: `{"key":"value"}`,
pubKey: peerpublicKey,
privKey: privateKey,
},
{
name: "Valid empty message",
data: "",
pubKey: peerpublicKey,
privKey: privateKey,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
encrypter := &encrypter{}
encrypted, err := encrypter.Encrypt(context.Background(), tt.data, tt.privKey, tt.pubKey)
if err != nil {
t.Errorf("Encrypt() expected no error, but got: %v", err)
}
// Verify the encrypted data is valid base64.
_, err = base64.StdEncoding.DecodeString(encrypted)
if err != nil {
t.Errorf("Encrypt() output is not valid base64: %v", err)
}
// Since we can't decrypt without the ephemeral private key,
// we can only verify that encryption doesn't return empty data.
if encrypted == "" {
t.Error("Encrypt() returned empty string")
}
// Verify the output is different from input (basic encryption check).
if encrypted == tt.data {
t.Error("Encrypt() output matches input, suggesting no encryption occurred")
}
})
}
}
// TestEncryptFailure tests encryption failure scenarios.
func TestEncryptFailure(t *testing.T) {
// Generate a valid key pair for testing.
_, privateKey := generateTestKeyPair(t)
peerpublicKey, _ := generateTestKeyPair(t)
tests := []struct {
name string
data string
publicKey string
privKey string
errorContains string
}{
{
name: "Invalid public key format",
data: "test data",
publicKey: "invalid-base64!@#$",
privKey: privateKey,
errorContains: "invalid public key",
},
{
name: "Invalid key bytes(public key)",
data: "test data",
publicKey: base64.StdEncoding.EncodeToString([]byte("invalid-key-bytes")),
privKey: privateKey,
errorContains: "failed to create public key",
},
{
name: "Invalid key bytes(private key)",
data: "test data",
publicKey: peerpublicKey,
privKey: base64.StdEncoding.EncodeToString([]byte("invalid-key-bytes")),
errorContains: "failed to create private key",
},
{
name: "Empty public key",
data: "test data",
publicKey: "",
privKey: privateKey,
errorContains: "invalid public key",
},
{
name: "Too short key",
data: "test data",
publicKey: base64.StdEncoding.EncodeToString([]byte{1, 2, 3, 4}),
privKey: privateKey,
errorContains: "failed to create public key",
},
{
name: "Invalid private key",
data: "test data",
publicKey: peerpublicKey,
privKey: "invalid-base64!@#$",
errorContains: "invalid private key",
},
{
name: "Empty private key",
data: "test data",
publicKey: peerpublicKey,
privKey: "",
errorContains: "invalid private key",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
encrypter := &encrypter{}
_, err := encrypter.Encrypt(context.Background(), tt.data, tt.privKey, tt.publicKey)
if err != nil && !strings.Contains(err.Error(), tt.errorContains) {
t.Errorf("Encrypt() error = %v, want error containing %q", err, tt.errorContains)
}
})
}
}
// TestNew tests the creation of new encrypter instances.
func TestNew(t *testing.T) {
tests := []struct {
name string
ctx context.Context
}{
{
name: "Success",
ctx: context.Background(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
encrypter, _, err := New(tt.ctx)
if err == nil && encrypter == nil {
t.Error("New() returned nil encrypter")
}
})
}
}

View File

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

View File

@@ -0,0 +1,122 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TODO: Will Split this into success and fail (two test cases)
func TestProviderNew(t *testing.T) {
testCases := []struct {
name string
config map[string]string
expectedError bool
expectedStatus int
prepareRequest func(req *http.Request)
}{
{
name: "No Config",
config: map[string]string{},
expectedError: true,
expectedStatus: http.StatusOK,
prepareRequest: func(req *http.Request) {
// Add minimal required headers.
req.Header.Set("context", "test-context")
req.Header.Set("transaction_id", "test-transaction")
},
},
{
name: "Success with BPP role",
config: map[string]string{
"role": "bpp",
},
expectedError: false,
expectedStatus: http.StatusOK,
prepareRequest: func(req *http.Request) {
// Add headers matching the check keys.
req.Header.Set("context", "test-context")
req.Header.Set("transaction_id", "test-transaction")
req.Header.Set("bpp_id", "bpp-456")
},
},
{
name: "Missing role configuration",
config: map[string]string{
// No role specified
},
expectedError: true,
prepareRequest: func(req *http.Request) {
req.Header.Set("context", "test-context")
req.Header.Set("transaction_id", "test-transaction")
},
},
{
name: "Invalid role configuration",
config: map[string]string{
"role": "invalid-role",
},
expectedError: true,
prepareRequest: func(req *http.Request) {
req.Header.Set("context", "test-context")
req.Header.Set("transaction_id", "test-transaction")
},
},
{
name: "passing the contextKeys",
config: map[string]string{
"role": "bpp",
"contextKeys": "transaction_id,message_id",
},
expectedError: false,
expectedStatus: http.StatusOK,
prepareRequest: func(req *http.Request) {
req.Header.Set("context", "test-context")
req.Header.Set("transaction_id", "test-transaction")
req.Header.Set("bpp_id", "bpp1")
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
requestBody := `{
"context": {
"transaction_id": "abc"
}
}`
p := provider{}
middleware, err := p.New(context.Background(), tc.config)
if tc.expectedError {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.NotNil(t, middleware)
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest("POST", "/", strings.NewReader(requestBody))
req.Header.Set("Content-Type", "application/json")
if tc.prepareRequest != nil {
tc.prepareRequest(req)
}
w := httptest.NewRecorder()
middlewaredHandler := middleware(testHandler)
middlewaredHandler.ServeHTTP(w, req)
assert.Equal(t, tc.expectedStatus, w.Code, "Unexpected response status")
responseBody := w.Body.String()
t.Logf("Response Body: %s", responseBody)
})
}
}

View File

@@ -0,0 +1,90 @@
package reqpreprocessor
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/beckn/beckn-onix/pkg/log"
"github.com/beckn/beckn-onix/pkg/model"
)
// Config represents the configuration for the request preprocessor middleware.
type Config struct {
Role string
ContextKeys []string
}
const contextKey = "context"
// NewPreProcessor returns a middleware that processes the incoming request,
// extracts the context field from the body, and adds relevant values (like subscriber ID).
func NewPreProcessor(cfg *Config) (func(http.Handler) http.Handler, error) {
if err := validateConfig(cfg); err != nil {
return nil, 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
}
// Extract context from request.
reqContext, ok := req["context"].(map[string]interface{})
if !ok {
http.Error(w, fmt.Sprintf("%s field not found or invalid.", contextKey), http.StatusBadRequest)
return
}
var subID any
switch cfg.Role {
case "bap":
subID = reqContext["bap_id"]
case "bpp":
subID = reqContext["bpp_id"]
}
if subID != nil {
log.Debugf(ctx, "adding subscriberId to request:%s, %v", model.ContextKeySubscriberID, subID)
ctx = context.WithValue(ctx, model.ContextKeySubscriberID, subID)
}
for _, key := range cfg.ContextKeys {
ctxKey, _ := model.ParseContextKey(key)
if v, ok := reqContext[key]; ok {
ctx = context.WithValue(ctx, ctxKey, v)
}
}
r.Body = io.NopCloser(bytes.NewBuffer(body))
r.ContentLength = int64(len(body))
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}, nil
}
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'")
}
for _, key := range cfg.ContextKeys {
if _, err := model.ParseContextKey(key); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,269 @@
package reqpreprocessor
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/beckn/beckn-onix/pkg/model"
)
// ToDo Separate Middleware creation and execution.
func TestNewPreProcessorSuccessCases(t *testing.T) {
tests := []struct {
name string
config *Config
requestBody map[string]any
expectedID string
}{
{
name: "BAP role with valid context",
config: &Config{
Role: "bap",
},
requestBody: map[string]interface{}{
"context": map[string]interface{}{
"bap_id": "bap-123",
"message_id": "msg-123",
},
"message": map[string]interface{}{
"key": "value",
},
},
expectedID: "bap-123",
},
{
name: "BPP role with valid context",
config: &Config{
Role: "bpp",
},
requestBody: map[string]interface{}{
"context": map[string]interface{}{
"bpp_id": "bpp-456",
"message_id": "msg-456",
},
"message": map[string]interface{}{
"key": "value",
},
},
expectedID: "bpp-456",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
middleware, err := NewPreProcessor(tt.config)
if err != nil {
t.Fatalf("NewPreProcessor() error = %v", err)
}
bodyBytes, err := json.Marshal(tt.requestBody)
if err != nil {
t.Fatalf("Failed to marshal request body: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/test", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
var gotSubID interface{}
dummyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
gotSubID = ctx.Value(model.ContextKeySubscriberID)
w.WriteHeader(http.StatusOK)
// Verify subscriber ID
subID := ctx.Value(model.ContextKeySubscriberID)
if subID == nil {
t.Errorf("Expected subscriber ID but got none %s", ctx)
return
}
// Verify the correct ID was set based on role
expectedKey := "bap_id"
if tt.config.Role == "bpp" {
expectedKey = "bpp_id"
}
expectedID := tt.requestBody["context"].(map[string]interface{})[expectedKey]
if subID != expectedID {
t.Errorf("Expected subscriber ID %v, got %v", expectedID, subID)
}
})
middleware(dummyHandler).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("Expected status code 200, but got %d", rec.Code)
return
}
// Verify subscriber ID
if gotSubID == nil {
t.Error("Expected subscriber_id to be set in context but got nil")
return
}
subID, ok := gotSubID.(string)
if !ok {
t.Errorf("Expected subscriber_id to be string, got %T", gotSubID)
return
}
if subID != tt.expectedID {
t.Errorf("Expected subscriber_id %q, got %q", tt.expectedID, subID)
}
})
}
}
func TestNewPreProcessorErrorCases(t *testing.T) {
tests := []struct {
name string
config *Config
requestBody interface{}
expectedCode int
expectErr bool
errMsg string
}{
{
name: "Missing context",
config: &Config{
Role: "bap",
},
requestBody: map[string]any{
"otherKey": "value",
},
expectedCode: http.StatusBadRequest,
expectErr: false,
errMsg: "context field not found or invalid",
},
{
name: "Invalid context type",
config: &Config{
Role: "bap",
},
requestBody: map[string]any{
"context": "not-a-map",
},
expectedCode: http.StatusBadRequest,
expectErr: false,
errMsg: "context field not found or invalid",
},
{
name: "Nil config",
config: nil,
requestBody: map[string]any{},
expectedCode: http.StatusInternalServerError,
expectErr: true,
errMsg: "config cannot be nil",
},
{
name: "Invalid role",
config: &Config{
Role: "invalid-role",
},
requestBody: map[string]interface{}{
"context": map[string]interface{}{
"bap_id": "bap-123",
},
},
expectedCode: http.StatusInternalServerError,
expectErr: true,
errMsg: "role must be either 'bap' or 'bpp'",
},
{
name: "Missing subscriber ID",
config: &Config{
Role: "bap",
},
requestBody: map[string]interface{}{
"context": map[string]interface{}{
"message_id": "msg-123",
},
},
expectedCode: http.StatusOK,
expectErr: false,
},
{
name: "Invalid JSON body",
config: &Config{
Role: "bap",
},
requestBody: "{invalid-json}",
expectedCode: http.StatusBadRequest,
expectErr: false,
errMsg: "failed to decode request body",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
middleware, err := NewPreProcessor(tt.config)
if tt.expectErr {
if err == nil {
t.Errorf("Expected an error for NewPreProcessor(%s), but got none", tt.config)
} else if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("Expected error to contain %q, got %v", tt.errMsg, err)
}
return
}
if err != nil {
t.Fatalf("Unexpected error while creating middleware: %v", err)
}
bodyBytes, _ := json.Marshal(tt.requestBody)
req := httptest.NewRequest(http.MethodPost, "/test", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
dummyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
middleware(dummyHandler).ServeHTTP(rec, req)
if rec.Code != tt.expectedCode {
t.Errorf("Expected status code %d, but got %d", tt.expectedCode, rec.Code)
}
})
}
}
func TestNewPreProcessorAddsSubscriberIDToContext(t *testing.T) {
cfg := &Config{Role: "bap"}
middleware, err := NewPreProcessor(cfg)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
samplePayload := map[string]interface{}{
"context": map[string]interface{}{
"bap_id": "bap.example.com",
},
}
bodyBytes, _ := json.Marshal(samplePayload)
var receivedSubscriberID interface{}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedSubscriberID = r.Context().Value(model.ContextKeySubscriberID)
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest("POST", "/", strings.NewReader(string(bodyBytes)))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
middleware(handler).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("Expected status 200 OK, got %d", rr.Code)
}
if receivedSubscriberID != "bap.example.com" {
t.Errorf("Expected subscriber ID 'bap.example.com', got %v", receivedSubscriberID)
}
}

View File

@@ -0,0 +1,31 @@
package main
import (
"context"
"errors"
"github.com/beckn/beckn-onix/pkg/plugin/definition"
"github.com/beckn/beckn-onix/pkg/plugin/implementation/router"
)
// RouterProvider provides instances of Router.
type RouterProvider struct{}
// New initializes a new Router instance.
func (rp RouterProvider) New(ctx context.Context, config map[string]string) (definition.Router, func() error, error) {
if ctx == nil {
return nil, nil, errors.New("context cannot be nil")
}
// Parse the routingConfig key from the config map
routingConfig, ok := config["routingConfig"]
if !ok {
return nil, nil, errors.New("routingConfig is required in the configuration")
}
return router.New(ctx, &router.Config{
RoutingConfig: routingConfig,
})
}
// Provider is the exported symbol that the plugin manager will look for.
var Provider = RouterProvider{}

View File

@@ -0,0 +1,101 @@
package main
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// setupTestConfig creates a temporary directory and writes a sample routing rules file.
func setupTestConfig(t *testing.T) string {
t.Helper()
// Get project root (assuming testData is in project root)
_, filename, _, _ := runtime.Caller(0) // Path to plugin_test.go
projectRoot := filepath.Dir(filepath.Dir(filename)) // Move up from cmd/
yamlPath := filepath.Join(projectRoot, "testData", "bap_receiver.yaml")
// Copy to temp file (to test file loading logic)
tempDir := t.TempDir()
tempPath := filepath.Join(tempDir, "routingRules.yaml")
content, err := os.ReadFile(yamlPath)
if err != nil {
t.Fatalf("Failed to read test file: %v", err)
}
if err := os.WriteFile(tempPath, content, 0644); err != nil {
t.Fatalf("Failed to create temp config: %v", err)
}
return tempPath
}
// TestRouterProviderSuccess tests successful router creation.
func TestRouterProviderSuccess(t *testing.T) {
rulesFilePath := setupTestConfig(t)
defer os.RemoveAll(filepath.Dir(rulesFilePath))
provider := RouterProvider{}
router, _, err := provider.New(context.Background(), map[string]string{
"routingConfig": rulesFilePath,
})
if err != nil {
t.Fatalf("New() unexpected error: %v", err)
}
if router == nil {
t.Error("New() returned nil router, want non-nil")
}
}
// TestRouterProviderFailure tests the RouterProvider implementation for failure cases.
func TestRouterProviderFailure(t *testing.T) {
rulesFilePath := setupTestConfig(t)
defer os.RemoveAll(filepath.Dir(rulesFilePath))
// Define test cases
tests := []struct {
name string
ctx context.Context
config map[string]string
wantErr string
}{
{
name: "Empty routing config path",
ctx: context.Background(),
config: map[string]string{
"routingConfig": "",
},
wantErr: "failed to load routing rules: routingConfig path is empty",
},
{
name: "Missing routing config key",
ctx: context.Background(),
config: map[string]string{},
wantErr: "routingConfig is required in the configuration",
},
{
name: "Nil context",
ctx: nil,
config: map[string]string{"routingConfig": rulesFilePath},
wantErr: "context cannot be nil",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
provider := RouterProvider{}
_, _, err := provider.New(tt.ctx, tt.config)
// Check for expected error
if err == nil {
t.Fatalf("New(%v, %v) = nil error, want error containing %q", tt.ctx, tt.config, tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("New(%v, %v) = %v, want error containing %q", tt.ctx, tt.config, err, tt.wantErr)
}
})
}
}

View File

@@ -0,0 +1,250 @@
package router
import (
"context"
"encoding/json"
"fmt"
"net/url"
"os"
"path"
"strings"
"github.com/beckn/beckn-onix/pkg/model"
"gopkg.in/yaml.v3"
)
// Config holds the configuration for the Router plugin.
type Config struct {
RoutingConfig string `json:"routingConfig"`
}
// RoutingConfig represents the structure of the routing configuration file.
type routingConfig struct {
RoutingRules []routingRule `yaml:"routingRules"`
}
// Router implements Router interface.
type Router struct {
rules map[string]map[string]map[string]*model.Route // domain -> version -> endpoint -> route
}
// RoutingRule represents a single routing rule.
type routingRule struct {
Domain string `yaml:"domain"`
Version string `yaml:"version"`
TargetType string `yaml:"targetType"` // "url", "publisher", "bpp", or "bap"
Target target `yaml:"target,omitempty"`
Endpoints []string `yaml:"endpoints"`
}
// Target contains destination-specific details.
type target struct {
URL string `yaml:"url,omitempty"` // URL for "url" or gateway endpoint for "bpp"/"bap"
PublisherID string `yaml:"publisherId,omitempty"` // For "msgq" type
}
// TargetType defines possible target destinations.
const (
targetTypeURL = "url" // Route to a specific URL
targetTypePublisher = "publisher" // Route to a publisher
targetTypeBPP = "bpp" // Route to a BPP endpoint
targetTypeBAP = "bap" // Route to a BAP endpoint
)
// New initializes a new Router instance with the provided configuration.
// It loads and validates the routing rules from the specified YAML file.
// Returns an error if the configuration is invalid or the rules cannot be loaded.
func New(ctx context.Context, config *Config) (*Router, func() error, error) {
// Check if config is nil
if config == nil {
return nil, nil, fmt.Errorf("config cannot be nil")
}
router := &Router{
rules: make(map[string]map[string]map[string]*model.Route),
}
// Load rules at bootup
if err := router.loadRules(config.RoutingConfig); err != nil {
return nil, nil, fmt.Errorf("failed to load routing rules: %w", err)
}
return router, nil, nil
}
// LoadRules reads and parses routing rules from the YAML configuration file.
func (r *Router) loadRules(configPath string) error {
if configPath == "" {
return fmt.Errorf("routingConfig path is empty")
}
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("error reading config file at %s: %w", configPath, err)
}
var config routingConfig
if err := yaml.Unmarshal(data, &config); err != nil {
return fmt.Errorf("error parsing YAML: %w", err)
}
// Validate rules
if err := validateRules(config.RoutingRules); err != nil {
return fmt.Errorf("invalid routing rules: %w", err)
}
// Build the optimized rule map
for _, rule := range config.RoutingRules {
// Initialize domain map if not exists
if _, ok := r.rules[rule.Domain]; !ok {
r.rules[rule.Domain] = make(map[string]map[string]*model.Route)
}
// Initialize version map if not exists
if _, ok := r.rules[rule.Domain][rule.Version]; !ok {
r.rules[rule.Domain][rule.Version] = make(map[string]*model.Route)
}
// Add all endpoints for this rule
for _, endpoint := range rule.Endpoints {
var route *model.Route
switch rule.TargetType {
case targetTypePublisher:
route = &model.Route{
TargetType: rule.TargetType,
PublisherID: rule.Target.PublisherID,
}
case targetTypeURL:
parsedURL, err := url.Parse(rule.Target.URL)
if err != nil {
return fmt.Errorf("invalid URL in rule: %w", err)
}
route = &model.Route{
TargetType: rule.TargetType,
URL: parsedURL,
}
case targetTypeBPP, targetTypeBAP:
var parsedURL *url.URL
if rule.Target.URL != "" {
parsedURL, err = url.Parse(rule.Target.URL)
if err != nil {
return fmt.Errorf("invalid URL in rule: %w", err)
}
}
route = &model.Route{
TargetType: rule.TargetType,
URL: parsedURL,
}
}
r.rules[rule.Domain][rule.Version][endpoint] = route
}
}
return nil
}
// validateRules performs basic validation on the loaded routing rules.
func validateRules(rules []routingRule) error {
for _, rule := range rules {
// Ensure domain, version, and TargetType are present
if rule.Domain == "" || rule.Version == "" || rule.TargetType == "" {
return fmt.Errorf("invalid rule: domain, version, and targetType are required")
}
// Validate based on TargetType
switch rule.TargetType {
case targetTypeURL:
if rule.Target.URL == "" {
return fmt.Errorf("invalid rule: url is required for targetType 'url'")
}
if _, err := url.Parse(rule.Target.URL); err != nil {
return fmt.Errorf("invalid URL - %s: %w", rule.Target.URL, err)
}
case targetTypePublisher:
if rule.Target.PublisherID == "" {
return fmt.Errorf("invalid rule: publisherID is required for targetType 'publisher'")
}
case targetTypeBPP, targetTypeBAP:
if rule.Target.URL != "" {
if _, err := url.Parse(rule.Target.URL); err != nil {
return fmt.Errorf("invalid URL - %s defined in routing config for target type %s: %w", rule.Target.URL, rule.TargetType, err)
}
}
continue
default:
return fmt.Errorf("invalid rule: unknown targetType '%s'", rule.TargetType)
}
}
return nil
}
// 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
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)
}
// Extract the endpoint from the URL
endpoint := path.Base(url.Path)
// Lookup route in the optimized map
domainRules, ok := r.rules[requestBody.Context.Domain]
if !ok {
return nil, fmt.Errorf("no routing rules found for domain %s", requestBody.Context.Domain)
}
versionRules, ok := domainRules[requestBody.Context.Version]
if !ok {
return nil, fmt.Errorf("no routing rules found for domain %s version %s", requestBody.Context.Domain, requestBody.Context.Version)
}
route, ok := versionRules[endpoint]
if !ok {
return nil, fmt.Errorf("endpoint '%s' is not supported for domain %s and version %s in routing config",
endpoint, requestBody.Context.Domain, requestBody.Context.Version)
}
// Handle BPP/BAP routing with request URIs
switch route.TargetType {
case targetTypeBPP:
return handleProtocolMapping(route, requestBody.Context.BPPURI, endpoint)
case targetTypeBAP:
return handleProtocolMapping(route, requestBody.Context.BAPURI, endpoint)
}
return route, nil
}
// handleProtocolMapping handles both BPP and BAP routing with proper URL construction
func handleProtocolMapping(route *model.Route, npURI, endpoint string) (*model.Route, error) {
target := strings.TrimSpace(npURI)
if len(target) == 0 {
if route.URL == nil {
return nil, fmt.Errorf("could not determine destination for endpoint '%s': neither request contained a %s URI nor was a default URL configured in routing rules", endpoint, strings.ToUpper(route.TargetType))
}
return &model.Route{
TargetType: targetTypeURL,
URL: &url.URL{
Scheme: route.URL.Scheme,
Host: route.URL.Host,
Path: path.Join(route.URL.Path, endpoint),
},
}, nil
}
targetURL, err := url.Parse(target)
if err != nil {
return nil, fmt.Errorf("invalid %s URI - %s in request body for %s: %w", strings.ToUpper(route.TargetType), target, endpoint, err)
}
return &model.Route{
TargetType: targetTypeURL,
URL: &url.URL{
Scheme: targetURL.Scheme,
Host: targetURL.Host,
Path: path.Join(targetURL.Path, endpoint),
},
}, nil
}

View File

@@ -0,0 +1,486 @@
package router
import (
"context"
"embed"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
//go:embed testData/*
var testData embed.FS
func setupTestConfig(t *testing.T, yamlFileName string) string {
t.Helper()
configDir := t.TempDir()
content, err := testData.ReadFile("testData/" + yamlFileName)
if err != nil {
t.Fatalf("ReadFile() err = %v, want nil", err)
}
rulesPath := filepath.Join(configDir, "routing_rules.yaml")
if err := os.WriteFile(rulesPath, content, 0644); err != nil {
t.Fatalf("WriteFile() err = %v, want nil", err)
}
return rulesPath
}
// setupRouter is a helper function to create router instance.
func setupRouter(t *testing.T, configFile string) (*Router, func() error, string) {
rulesFilePath := setupTestConfig(t, configFile)
config := &Config{
RoutingConfig: rulesFilePath,
}
router, _, err := New(context.Background(), config)
if err != nil {
t.Fatalf("New failed: %v", err)
}
return router, nil, rulesFilePath
}
// TestNew tests the New function.
func TestNew(t *testing.T) {
ctx := context.Background()
// List of YAML files in the testData directory
yamlFiles := []string{
"bap_caller.yaml",
"bap_receiver.yaml",
"bpp_caller.yaml",
"bpp_receiver.yaml",
}
for _, yamlFile := range yamlFiles {
t.Run(yamlFile, func(t *testing.T) {
rulesFilePath := setupTestConfig(t, yamlFile)
defer os.RemoveAll(filepath.Dir(rulesFilePath))
// Define test cases
tests := []struct {
name string
config *Config
wantErr string
}{
{
name: "Valid configuration",
config: &Config{
RoutingConfig: rulesFilePath,
},
wantErr: "",
},
{
name: "Empty config",
config: nil,
wantErr: "config cannot be nil",
},
{
name: "Empty routing config path",
config: &Config{
RoutingConfig: "",
},
wantErr: "routingConfig path is empty",
},
{
name: "Routing config file does not exist",
config: &Config{
RoutingConfig: "/nonexistent/path/to/rules.yaml",
},
wantErr: "error reading config file",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router, _, err := New(ctx, tt.config)
// Check for expected error
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("New(%v) = %v, want error containing %q", tt.config, err, tt.wantErr)
}
return
}
// Ensure no error occurred
if err != nil {
t.Errorf("New(%v) = %v, want nil error", tt.config, err)
return
}
// Ensure the router and close function are not nil
if router == nil {
t.Errorf("New(%v, %v) = nil router, want non-nil", ctx, tt.config)
}
})
}
})
}
}
// TestValidateRulesSuccess tests the validate function for success cases.
func TestValidateRulesSuccess(t *testing.T) {
tests := []struct {
name string
rules []routingRule
}{
{
name: "Valid rules with url routing",
rules: []routingRule{
{
Domain: "retail",
Version: "1.0.0",
TargetType: "url",
Target: target{
URL: "https://example.com/api",
},
Endpoints: []string{"on_search", "on_select"},
},
},
},
{
name: "Valid rules with publisher routing",
rules: []routingRule{
{
Domain: "retail",
Version: "1.0.0",
TargetType: "publisher",
Target: target{
PublisherID: "example_topic",
},
Endpoints: []string{"on_search", "on_select"},
},
},
},
{
name: "Valid rules with bpp routing to gateway",
rules: []routingRule{
{
Domain: "retail",
Version: "1.0.0",
TargetType: "bpp",
Target: target{
URL: "https://mock_gateway.com/api",
},
Endpoints: []string{"search"},
},
},
},
{
name: "Valid rules with bpp routing",
rules: []routingRule{
{
Domain: "retail",
Version: "1.0.0",
TargetType: "bpp",
Endpoints: []string{"select"},
},
},
},
{
name: "Valid rules with bap routing",
rules: []routingRule{
{
Domain: "retail",
Version: "1.0.0",
TargetType: "bap",
Endpoints: []string{"select"},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateRules(tt.rules)
if err != nil {
t.Errorf("validateRules(%v) = %v, want nil error", tt.rules, err)
}
})
}
}
// TestValidateRulesFailure tests the validate function for failure cases.
func TestValidateRulesFailure(t *testing.T) {
tests := []struct {
name string
rules []routingRule
wantErr string
}{
{
name: "Missing domain",
rules: []routingRule{
{
Version: "1.0.0",
TargetType: "url",
Target: target{
URL: "https://example.com/api",
},
Endpoints: []string{"search", "select"},
},
},
wantErr: "invalid rule: domain, version, and targetType are required",
},
{
name: "Missing version",
rules: []routingRule{
{
Domain: "retail",
TargetType: "url",
Target: target{
URL: "https://example.com/api",
},
Endpoints: []string{"search", "select"},
},
},
wantErr: "invalid rule: domain, version, and targetType are required",
},
{
name: "Missing targetType",
rules: []routingRule{
{
Domain: "retail",
Version: "1.0.0",
Target: target{
URL: "https://example.com/api",
},
Endpoints: []string{"search", "select"},
},
},
wantErr: "invalid rule: domain, version, and targetType are required",
},
{
name: "Invalid targetType",
rules: []routingRule{
{
Domain: "retail",
Version: "1.0.0",
TargetType: "invalid",
Target: target{
URL: "https://example.com/api",
},
Endpoints: []string{"search", "select"},
},
},
wantErr: "invalid rule: unknown targetType 'invalid'",
},
{
name: "Missing url for targetType: url",
rules: []routingRule{
{
Domain: "retail",
Version: "1.0.0",
TargetType: "url",
Target: target{
// URL is missing
},
Endpoints: []string{"search", "select"},
},
},
wantErr: "invalid rule: url is required for targetType 'url'",
},
{
name: "Invalid URL format for targetType: url",
rules: []routingRule{
{
Domain: "retail",
Version: "1.0.0",
TargetType: "url",
Target: target{
URL: "htp:// invalid-url.com", // Invalid scheme
},
Endpoints: []string{"search"},
},
},
wantErr: `invalid URL - htp:// invalid-url.com: parse "htp:// invalid-url.com": invalid character " " in host name`,
},
{
name: "Missing topic_id for targetType: publisher",
rules: []routingRule{
{
Domain: "retail",
Version: "1.0.0",
TargetType: "publisher",
Target: target{
// PublisherID is missing
},
Endpoints: []string{"search", "select"},
},
},
wantErr: "invalid rule: publisherID is required for targetType 'publisher'",
},
{
name: "Invalid URL for BPP targetType",
rules: []routingRule{
{
Domain: "retail",
Version: "1.0.0",
TargetType: "bpp",
Target: target{
URL: "htp:// invalid-url.com", // Invalid URL
},
Endpoints: []string{"search"},
},
},
wantErr: `invalid URL - htp:// invalid-url.com defined in routing config for target type bpp: parse "htp:// invalid-url.com": invalid character " " in host name`,
},
{
name: "Invalid URL for BAP targetType",
rules: []routingRule{
{
Domain: "retail",
Version: "1.0.0",
TargetType: "bap",
Target: target{
URL: "http:// [invalid].com", // Invalid host
},
Endpoints: []string{"search"},
},
},
wantErr: `invalid URL - http:// [invalid].com defined in routing config for target type bap: parse "http:// [invalid].com": invalid character " " in host name`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateRules(tt.rules)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("validateRules(%v) = %v, want error containing %q", tt.rules, err, tt.wantErr)
}
})
}
}
// TestRouteSuccess tests the Route function for success cases.
func TestRouteSuccess(t *testing.T) {
ctx := context.Background()
// Define success test cases
tests := []struct {
name string
configFile string
url string
body string
}{
{
name: "Valid domain, version, and endpoint (bpp routing with gateway URL)",
configFile: "bap_caller.yaml",
url: "https://example.com/v1/ondc/search",
body: `{"context": {"domain": "ONDC:TRV10", "version": "2.0.0"}}`,
},
{
name: "Valid domain, version, and endpoint (bpp routing with bpp_uri)",
configFile: "bap_caller.yaml",
url: "https://example.com/v1/ondc/select",
body: `{"context": {"domain": "ONDC:TRV10", "version": "2.0.0", "bpp_uri": "https://bpp1.example.com"}}`,
},
{
name: "Valid domain, version, and endpoint (url routing)",
configFile: "bpp_receiver.yaml",
url: "https://example.com/v1/ondc/select",
body: `{"context": {"domain": "ONDC:TRV10", "version": "2.0.0"}}`,
},
{
name: "Valid domain, version, and endpoint (publisher routing)",
configFile: "bpp_receiver.yaml",
url: "https://example.com/v1/ondc/search",
body: `{"context": {"domain": "ONDC:TRV10", "version": "2.0.0"}}`,
},
{
name: "Valid domain, version, and endpoint (bap routing with bap_uri)",
configFile: "bpp_caller.yaml",
url: "https://example.com/v1/ondc/on_select",
body: `{"context": {"domain": "ONDC:TRV10", "version": "2.0.0", "bap_uri": "https://bap1.example.com"}}`,
},
{
name: "Valid domain, version, and endpoint (bpp routing with bpp_uri)",
configFile: "bap_receiver.yaml",
url: "https://example.com/v1/ondc/on_select",
body: `{"context": {"domain": "ONDC:TRV10", "version": "2.0.0", "bpp_uri": "https://bpp1.example.com"}}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router, _, rulesFilePath := setupRouter(t, tt.configFile)
defer os.RemoveAll(filepath.Dir(rulesFilePath))
parsedURL, _ := url.Parse(tt.url)
_, err := router.Route(ctx, parsedURL, []byte(tt.body))
// Ensure no error occurred
if err != nil {
t.Errorf("router.Route(%v, %v, %v) = %v, want nil error", ctx, parsedURL, []byte(tt.body), err)
}
})
}
}
// TestRouteFailure tests the Route function for failure cases.
func TestRouteFailure(t *testing.T) {
ctx := context.Background()
// Define failure test cases
tests := []struct {
name string
configFile string
url string
body string
wantErr string
}{
{
name: "Unsupported endpoint",
configFile: "bpp_receiver.yaml",
url: "https://example.com/v1/ondc/unsupported",
body: `{"context": {"domain": "ONDC:TRV11", "version": "2.0.0"}}`,
wantErr: "endpoint 'unsupported' is not supported for domain ONDC:TRV11 and version 2.0.0",
},
{
name: "No matching rule",
configFile: "bpp_receiver.yaml",
url: "https://example.com/v1/ondc/select",
body: `{"context": {"domain": "ONDC:SRV11", "version": "2.0.0"}}`,
wantErr: "no routing rules found for domain ONDC:SRV11",
},
{
name: "Missing bap_uri for bap routing",
configFile: "bpp_caller.yaml",
url: "https://example.com/v1/ondc/on_search",
body: `{"context": {"domain": "ONDC:TRV10", "version": "2.0.0"}}`,
wantErr: "could not determine destination for endpoint 'on_search': neither request contained a BAP URI nor was a default URL configured in routing rules",
},
{
name: "Missing bpp_uri for bpp routing",
configFile: "bap_caller.yaml",
url: "https://example.com/v1/ondc/select",
body: `{"context": {"domain": "ONDC:TRV10", "version": "2.0.0"}}`,
wantErr: "could not determine destination for endpoint 'select': neither request contained a BPP URI nor was a default URL configured in routing rules",
},
{
name: "Invalid bpp_uri format in request",
configFile: "bap_caller.yaml",
url: "https://example.com/v1/ondc/select",
body: `{"context": {"domain": "ONDC:TRV10", "version": "2.0.0", "bpp_uri": "htp:// invalid-url"}}`, // Invalid scheme (htp instead of http)
wantErr: `invalid BPP URI - htp:// invalid-url in request body for select: parse "htp:// invalid-url": invalid character " " in host name`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router, _, rulesFilePath := setupRouter(t, tt.configFile)
defer os.RemoveAll(filepath.Dir(rulesFilePath))
parsedURL, _ := url.Parse(tt.url)
_, err := router.Route(ctx, parsedURL, []byte(tt.body))
// Check for expected error
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("Route(%q, %q) = %v, want error containing %q", tt.url, tt.body, err, tt.wantErr)
}
})
}
}

View File

@@ -0,0 +1,25 @@
routingRules:
- domain: ONDC:TRV10
version: 2.0.0
targetType: bpp
target:
url: https://gateway.example.com
endpoints:
- search
- domain: ONDC:TRV10
version: 2.0.0
targetType: bpp
endpoints:
- select
- init
- confirm
- status
- cancel
- domain: ONDC:TRV12
version: 2.0.0
targetType: bpp
endpoints:
- select
- init
- confirm
- status

View File

@@ -0,0 +1,20 @@
routingRules:
- domain: ONDC:TRV10
version: 2.0.0
targetType: url
target:
url: https://services-backend/trv/v1
endpoints:
- on_select
- on_init
- on_confirm
- on_status
- on_update
- on_cancel
- domain: ONDC:TRV10
version: 2.0.0
targetType: publisher
target:
publisherId: trv_topic_id1
endpoints:
- on_search

View File

@@ -0,0 +1,23 @@
routingRules:
- domain: ONDC:TRV10
version: 2.0.0
targetType: bap
endpoints:
- on_search
- on_select
- on_init
- on_confirm
- on_status
- on_update
- on_cancel
- domain: ONDC:TRV11
version: 2.0.0
targetType: bap
endpoints:
- on_search
- on_select
- on_init
- on_confirm
- on_status
- on_update
- on_cancel

View File

@@ -0,0 +1,28 @@
routingRules:
- domain: ONDC:TRV10
version: 2.0.0
targetType: url
target:
url: https://services-backend/trv/v1
endpoints:
- select
- init
- confirm
- status
- cancel
- domain: ONDC:TRV10
version: 2.0.0
targetType: publisher
target:
publisherId: trv_topic_id1
endpoints:
- search
- domain: ONDC:TRV11
version: 2.0.0
targetType: url
target:
url: https://services-backend/trv/v1
endpoints:
- select
- init
- confirm

View File

@@ -0,0 +1,33 @@
package main
import (
"context"
"errors"
"github.com/beckn/beckn-onix/pkg/plugin/definition"
"github.com/beckn/beckn-onix/pkg/plugin/implementation/schemavalidator"
)
// schemaValidatorProvider provides instances of schemaValidator.
type schemaValidatorProvider struct{}
// New initializes a new Verifier instance.
func (vp schemaValidatorProvider) New(ctx context.Context, config map[string]string) (definition.SchemaValidator, func() error, error) {
if ctx == nil {
return nil, nil, errors.New("context cannot be nil")
}
// Extract schemaDir from the config map
schemaDir, ok := config["schemaDir"]
if !ok || schemaDir == "" {
return nil, nil, errors.New("config must contain 'schemaDir'")
}
// Create a new schemaValidator instance with the provided configuration
return schemavalidator.New(ctx, &schemavalidator.Config{
SchemaDir: schemaDir,
})
}
// Provider is the exported symbol that the plugin manager will look for.
var Provider = schemaValidatorProvider{}

View File

@@ -0,0 +1,150 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// setupTestSchema creates a temporary directory and writes a sample schema file.
func setupTestSchema(t *testing.T) string {
t.Helper()
// Create a temporary directory for the schema
schemaDir, err := os.MkdirTemp("", "schemas")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
// Create the directory structure for the schema file
schemaFilePath := filepath.Join(schemaDir, "example", "1.0", "test_schema.json")
if err := os.MkdirAll(filepath.Dir(schemaFilePath), 0755); err != nil {
t.Fatalf("Failed to create schema directory structure: %v", err)
}
// Define a sample schema
schemaContent := `{
"type": "object",
"properties": {
"context": {
"type": "object",
"properties": {
"domain": {"type": "string"},
"version": {"type": "string"}
},
"required": ["domain", "version"]
}
},
"required": ["context"]
}`
// Write the schema to the file
if err := os.WriteFile(schemaFilePath, []byte(schemaContent), 0644); err != nil {
t.Fatalf("Failed to write schema file: %v", err)
}
return schemaDir
}
// TestValidatorProviderSuccess tests successful ValidatorProvider implementation.
func TestValidatorProviderSuccess(t *testing.T) {
schemaDir := setupTestSchema(t)
defer os.RemoveAll(schemaDir)
// Define test cases.
tests := []struct {
name string
ctx context.Context
config map[string]string
expectedError string
}{
{
name: "Valid schema directory",
ctx: context.Background(), // Valid context
config: map[string]string{"schemaDir": schemaDir},
expectedError: "",
},
}
// Test using table-driven tests
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
vp := schemaValidatorProvider{}
schemaValidator, _, err := vp.New(tt.ctx, tt.config)
// Ensure no error occurred
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
// Ensure the schemaValidator is not nil
if schemaValidator == nil {
t.Error("expected a non-nil schemaValidator, got nil")
}
})
}
}
// TestValidatorProviderSuccess tests cases where ValidatorProvider creation should fail.
func TestValidatorProviderFailure(t *testing.T) {
schemaDir := setupTestSchema(t)
defer os.RemoveAll(schemaDir)
// Define test cases.
tests := []struct {
name string
ctx context.Context
config map[string]string
expectedError string
}{
{
name: "Config is empty",
ctx: context.Background(),
config: map[string]string{},
expectedError: "config must contain 'schemaDir'",
},
{
name: "schemaDir is empty",
ctx: context.Background(),
config: map[string]string{"schemaDir": ""},
expectedError: "config must contain 'schemaDir'",
},
{
name: "Invalid schema directory",
ctx: context.Background(), // Valid context
config: map[string]string{"schemaDir": "/invalid/dir"},
expectedError: "failed to initialise schemaValidator: schema directory does not exist: /invalid/dir",
},
{
name: "Nil context",
ctx: nil, // Nil context
config: map[string]string{"schemaDir": schemaDir},
expectedError: "context cannot be nil",
},
}
// Test using table-driven tests
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
vp := schemaValidatorProvider{}
_, _, err := vp.New(tt.ctx, tt.config)
// Check for expected error
if tt.expectedError != "" {
if err == nil || !strings.Contains(err.Error(), tt.expectedError) {
t.Errorf("expected error %q, got %v", tt.expectedError, err)
}
return
}
// Ensure no error occurred
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
})
}
}

View File

@@ -0,0 +1,193 @@
package schemavalidator
import (
"context"
"encoding/json"
"fmt"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"github.com/beckn/beckn-onix/pkg/log"
"github.com/beckn/beckn-onix/pkg/model"
"github.com/santhosh-tekuri/jsonschema/v6"
)
// Payload represents the structure of the data payload with context information.
type payload struct {
Context struct {
Domain string `json:"domain"`
Version string `json:"version"`
} `json:"context"`
}
// schemaValidator implements the Validator interface.
type schemaValidator struct {
config *Config
schemaCache map[string]*jsonschema.Schema
}
// Config struct for SchemaValidator.
type Config struct {
SchemaDir string
}
// New creates a new ValidatorProvider instance.
func New(ctx context.Context, config *Config) (*schemaValidator, func() error, error) {
// Check if config is nil
if config == nil {
return nil, nil, fmt.Errorf("config cannot be nil")
}
v := &schemaValidator{
config: config,
schemaCache: make(map[string]*jsonschema.Schema),
}
// Call Initialise function to load schemas and get validators
if err := v.initialise(); err != nil {
return nil, nil, fmt.Errorf("failed to initialise schemaValidator: %v", err)
}
return v, nil, nil
}
// Validate validates the given data against the schema.
func (v *schemaValidator) Validate(ctx context.Context, url *url.URL, data []byte) error {
var payloadData payload
err := json.Unmarshal(data, &payloadData)
if err != nil {
return model.NewBadReqErr(fmt.Errorf("failed to parse JSON payload: %v", err))
}
// Extract domain, version, and endpoint from the payload and uri.
cxtDomain := payloadData.Context.Domain
version := payloadData.Context.Version
version = fmt.Sprintf("v%s", version)
endpoint := path.Base(url.String())
log.Debugf(ctx, "Handling request for endpoint: %s", endpoint)
domain := strings.ToLower(cxtDomain)
domain = strings.ReplaceAll(domain, ":", "_")
// Construct the schema file name.
schemaFileName := fmt.Sprintf("%s_%s_%s", domain, version, endpoint)
// Retrieve the schema from the cache.
schema, exists := v.schemaCache[schemaFileName]
if !exists {
return model.NewBadReqErr(fmt.Errorf("schema not found for domain: %s", domain))
}
var jsonData any
if err := json.Unmarshal(data, &jsonData); err != nil {
return model.NewBadReqErr(fmt.Errorf("failed to parse JSON data: %v", err))
}
err = schema.Validate(jsonData)
if err != nil {
// Handle schema validation errors
if validationErr, ok := err.(*jsonschema.ValidationError); ok {
// Convert validation errors into an array of SchemaValError
var schemaErrors []model.Error
for _, cause := range validationErr.Causes {
// Extract the path and message from the validation error
path := strings.Join(cause.InstanceLocation, ".") // JSON path to the invalid field
message := cause.Error() // Validation error message
// Append the error to the schemaErrors array
schemaErrors = append(schemaErrors, model.Error{
Paths: path,
Message: message,
})
}
// Return the array of schema validation errors
return &model.SchemaValidationErr{Errors: schemaErrors}
}
return fmt.Errorf("validation failed: %v", err)
}
// Return nil if validation succeeds
return nil
}
// Initialise initialises the validator provider by compiling all the JSON schema files
// from the specified directory and storing them in a cache indexed by their schema filenames.
func (v *schemaValidator) initialise() error {
schemaDir := v.config.SchemaDir
// Check if the directory exists and is accessible.
info, err := os.Stat(schemaDir)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("schema directory does not exist: %s", schemaDir)
}
return fmt.Errorf("failed to access schema directory: %v", err)
}
if !info.IsDir() {
return fmt.Errorf("provided schema path is not a directory: %s", schemaDir)
}
compiler := jsonschema.NewCompiler()
// Helper function to process directories recursively.
var processDir func(dir string) error
processDir = func(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("failed to read directory: %v", err)
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if entry.IsDir() {
// Recursively process subdirectories.
if err := processDir(path); err != nil {
return err
}
} else if filepath.Ext(entry.Name()) == ".json" {
// Process JSON files.
compiledSchema, err := compiler.Compile(path)
if err != nil {
return fmt.Errorf("failed to compile JSON schema from file %s: %v", entry.Name(), err)
}
// Use relative path from schemaDir to avoid absolute paths and make schema keys domain/version specific.
relativePath, err := filepath.Rel(schemaDir, path)
if err != nil {
return fmt.Errorf("failed to get relative path for file %s: %v", entry.Name(), err)
}
// Split the relative path to get domain, version, and schema.
parts := strings.Split(relativePath, string(os.PathSeparator))
// Ensure that the file path has at least 3 parts: domain, version, and schema file.
if len(parts) < 3 {
return fmt.Errorf("invalid schema file structure, expected domain/version/schema.json but got: %s", relativePath)
}
// Extract domain, version, and schema filename from the parts.
// Validate that the extracted parts are non-empty.
domain := strings.TrimSpace(parts[0])
version := strings.TrimSpace(parts[1])
schemaFileName := strings.TrimSpace(parts[2])
schemaFileName = strings.TrimSuffix(schemaFileName, ".json")
if domain == "" || version == "" || schemaFileName == "" {
return fmt.Errorf("invalid schema file structure, one or more components are empty. Relative path: %s", relativePath)
}
// Construct a unique key combining domain, version, and schema name (e.g., ondc_trv10_v2.0.0_schema).
uniqueKey := fmt.Sprintf("%s_%s_%s", domain, version, schemaFileName)
// Store the compiled schema in the SchemaCache using the unique key.
v.schemaCache[uniqueKey] = compiledSchema
}
}
return nil
}
// Start processing from the root schema directory.
if err := processDir(schemaDir); err != nil {
return fmt.Errorf("failed to read schema directory: %v", err)
}
return nil
}

View File

@@ -0,0 +1,353 @@
package schemavalidator
import (
"context"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/santhosh-tekuri/jsonschema/v6"
)
// setupTestSchema creates a temporary directory and writes a sample schema file.
func setupTestSchema(t *testing.T) string {
t.Helper()
// Create a temporary directory for the schema
schemaDir, err := os.MkdirTemp("", "schemas")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
// Create the directory structure for the schema file
schemaFilePath := filepath.Join(schemaDir, "example", "v1.0", "endpoint.json")
if err := os.MkdirAll(filepath.Dir(schemaFilePath), 0755); err != nil {
t.Fatalf("Failed to create schema directory structure: %v", err)
}
// Define a sample schema
schemaContent := `{
"type": "object",
"properties": {
"context": {
"type": "object",
"properties": {
"domain": {"type": "string"},
"version": {"type": "string"},
"action": {"type": "string"}
},
"required": ["domain", "version", "action"]
}
},
"required": ["context"]
}`
// Write the schema to the file
if err := os.WriteFile(schemaFilePath, []byte(schemaContent), 0644); err != nil {
t.Fatalf("Failed to write schema file: %v", err)
}
return schemaDir
}
func TestValidator_Validate_Success(t *testing.T) {
tests := []struct {
name string
url string
payload string
wantErr bool
}{
{
name: "Valid payload",
url: "http://example.com/endpoint",
payload: `{"context": {"domain": "example", "version": "1.0", "action": "endpoint"}}`,
wantErr: false,
},
}
// Setup a temporary schema directory for testing
schemaDir := setupTestSchema(t)
defer os.RemoveAll(schemaDir)
config := &Config{SchemaDir: schemaDir}
v, _, err := New(context.Background(), config)
if err != nil {
t.Fatalf("Failed to create validator: %v", err)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u, _ := url.Parse(tt.url)
err := v.Validate(context.Background(), u, []byte(tt.payload))
if err != nil {
t.Errorf("Unexpected error: %v", err)
} else {
t.Logf("Test %s passed with no errors", tt.name)
}
})
}
}
func TestValidator_Validate_Failure(t *testing.T) {
tests := []struct {
name string
url string
payload string
wantErr string
}{
{
name: "Invalid JSON payload",
url: "http://example.com/endpoint",
payload: `{"context": {"domain": "example", "version": "1.0"`,
wantErr: "failed to parse JSON payload",
},
{
name: "Schema validation failure",
url: "http://example.com/endpoint",
payload: `{"context": {"domain": "example", "version": "1.0"}}`,
wantErr: "context: at '/context': missing property 'action'",
},
{
name: "Schema not found",
url: "http://example.com/unknown_endpoint",
payload: `{"context": {"domain": "example", "version": "1.0"}}`,
wantErr: "schema not found for domain",
},
}
// Setup a temporary schema directory for testing
schemaDir := setupTestSchema(t)
defer os.RemoveAll(schemaDir)
config := &Config{SchemaDir: schemaDir}
v, _, err := New(context.Background(), config)
if err != nil {
t.Fatalf("Failed to create validator: %v", err)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u, _ := url.Parse(tt.url)
err := v.Validate(context.Background(), u, []byte(tt.payload))
if tt.wantErr != "" {
if err == nil {
t.Errorf("Expected error containing '%s', but got nil", tt.wantErr)
} else if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("Expected error containing '%s', but got '%v'", tt.wantErr, err)
} else {
t.Logf("Test %s passed with expected error: %v", tt.name, err)
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
} else {
t.Logf("Test %s passed with no errors", tt.name)
}
}
})
}
}
func TestValidator_Initialise(t *testing.T) {
tests := []struct {
name string
setupFunc func(schemaDir string) error
wantErr string
}{
{
name: "Schema directory does not exist",
setupFunc: func(schemaDir string) error {
// Do not create the schema directory
return nil
},
wantErr: "schema directory does not exist",
},
{
name: "Schema path is not a directory",
setupFunc: func(schemaDir string) error {
// Create a file instead of a directory
return os.WriteFile(schemaDir, []byte{}, 0644)
},
wantErr: "provided schema path is not a directory",
},
{
name: "Invalid schema file structure",
setupFunc: func(schemaDir string) error {
// Create an invalid schema file structure
invalidSchemaFile := filepath.Join(schemaDir, "invalid_schema.json")
if err := os.MkdirAll(filepath.Dir(invalidSchemaFile), 0755); err != nil {
t.Fatalf("Failed to create directory: %v", err)
}
return os.WriteFile(invalidSchemaFile, []byte(`{}`), 0644)
},
wantErr: "invalid schema file structure",
},
{
name: "Failed to compile JSON schema",
setupFunc: func(schemaDir string) error {
// Create a schema file with invalid JSON
invalidSchemaFile := filepath.Join(schemaDir, "example", "1.0", "endpoint.json")
if err := os.MkdirAll(filepath.Dir(invalidSchemaFile), 0755); err != nil {
t.Fatalf("Failed to create directory: %v", err)
}
return os.WriteFile(invalidSchemaFile, []byte(`{invalid json}`), 0644)
},
wantErr: "failed to compile JSON schema",
},
{
name: "Invalid schema file structure with empty components",
setupFunc: func(schemaDir string) error {
// Create a schema file with empty domain, version, or schema name
invalidSchemaFile := filepath.Join(schemaDir, "", "1.0", "endpoint.json")
if err := os.MkdirAll(filepath.Dir(invalidSchemaFile), 0755); err != nil {
t.Fatalf("Failed to create directory: %v", err)
}
return os.WriteFile(invalidSchemaFile, []byte(`{
"type": "object",
"properties": {
"context": {
"type": "object",
"properties": {
"domain": {"type": "string"},
"version": {"type": "string"}
},
"required": ["domain", "version"]
}
},
"required": ["context"]
}`), 0644)
},
wantErr: "failed to read schema directory: invalid schema file structure, expected domain/version/schema.json but got: 1.0/endpoint.json",
},
{
name: "Failed to read directory",
setupFunc: func(schemaDir string) error {
// Create a directory and remove read permissions
if err := os.MkdirAll(schemaDir, 0000); err != nil {
t.Fatalf("Failed to create directory: %v", err)
}
return nil
},
wantErr: "failed to read directory",
},
{
name: "Valid schema directory",
setupFunc: func(schemaDir string) error {
// Create a valid schema file
validSchemaFile := filepath.Join(schemaDir, "example", "1.0", "endpoint.json")
if err := os.MkdirAll(filepath.Dir(validSchemaFile), 0755); err != nil {
t.Fatalf("Failed to create directory: %v", err)
}
return os.WriteFile(validSchemaFile, []byte(`{
"type": "object",
"properties": {
"context": {
"type": "object",
"properties": {
"domain": {"type": "string"},
"version": {"type": "string"}
},
"required": ["domain", "version"]
}
},
"required": ["context"]
}`), 0644)
},
wantErr: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup a temporary schema directory for testing
schemaDir := filepath.Join(os.TempDir(), "schemas")
defer os.RemoveAll(schemaDir)
// Run the setup function to prepare the test case
if err := tt.setupFunc(schemaDir); err != nil {
t.Fatalf("setupFunc() error = %v", err)
}
config := &Config{SchemaDir: schemaDir}
v := &schemaValidator{
config: config,
schemaCache: make(map[string]*jsonschema.Schema),
}
err := v.initialise()
if (err != nil && !strings.Contains(err.Error(), tt.wantErr)) || (err == nil && tt.wantErr != "") {
t.Errorf("Error: initialise() returned error = %v, expected error = %v", err, tt.wantErr)
} else if err == nil {
t.Logf("Test %s passed: validator initialized successfully", tt.name)
} else {
t.Logf("Test %s passed with expected error: %v", tt.name, err)
}
})
}
}
func TestValidatorNew_Success(t *testing.T) {
schemaDir := setupTestSchema(t)
defer os.RemoveAll(schemaDir)
config := &Config{SchemaDir: schemaDir}
_, _, err := New(context.Background(), config)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
func TestValidatorNewFailure(t *testing.T) {
tests := []struct {
name string
config *Config
setupFunc func(schemaDir string) error
wantErr string
}{
{
name: "Config is nil",
config: nil,
setupFunc: func(schemaDir string) error {
return nil
},
wantErr: "config cannot be nil",
},
{
name: "Failed to initialise validators",
config: &Config{
SchemaDir: "/invalid/path",
},
setupFunc: func(schemaDir string) error {
// Do not create the schema directory
return nil
},
wantErr: "ailed to initialise schemaValidator: schema directory does not exist: /invalid/path",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Run the setup function if provided
if tt.setupFunc != nil {
schemaDir := ""
if tt.config != nil {
schemaDir = tt.config.SchemaDir
}
if err := tt.setupFunc(schemaDir); err != nil {
t.Fatalf("Setup function failed: %v", err)
}
}
// Call the New function with the test config
_, _, err := New(context.Background(), tt.config)
if (err != nil && !strings.Contains(err.Error(), tt.wantErr)) || (err == nil && tt.wantErr != "") {
t.Errorf("Error: New() returned error = %v, expected error = %v", err, tt.wantErr)
} else {
t.Logf("Test %s passed with expected error: %v", tt.name, err)
}
})
}
}

View File

@@ -0,0 +1,24 @@
package main
import (
"context"
"errors"
"github.com/beckn/beckn-onix/pkg/plugin/definition"
"github.com/beckn/beckn-onix/pkg/plugin/implementation/signer"
)
// SignerProvider implements the definition.SignerProvider interface.
type SignerProvider struct{}
// New creates a new Signer instance using the provided configuration.
func (p SignerProvider) New(ctx context.Context, config map[string]string) (definition.Signer, func() error, error) {
if ctx == nil {
return nil, nil, errors.New("context cannot be nil")
}
return signer.New(ctx, &signer.Config{})
}
// Provider is the exported symbol that the plugin manager will look for.
var Provider = SignerProvider{}

View File

@@ -0,0 +1,101 @@
package main
import (
"context"
"testing"
)
// TestSignerProviderSuccess verifies successful scenarios for SignerProvider.
func TestSignerProviderSuccess(t *testing.T) {
provider := SignerProvider{}
successTests := []struct {
name string
ctx context.Context
config map[string]string
}{
{
name: "Valid Config",
ctx: context.Background(),
config: map[string]string{},
},
{
name: "Unexpected Config Key",
ctx: context.Background(),
config: map[string]string{"unexpected_key": "some_value"},
},
{
name: "Empty Config",
ctx: context.Background(),
config: map[string]string{},
},
{
name: "Config with empty TTL",
ctx: context.Background(),
config: map[string]string{"ttl": ""},
},
{
name: "Config with negative TTL",
ctx: context.Background(),
config: map[string]string{"ttl": "-100"},
},
{
name: "Config with non-numeric TTL",
ctx: context.Background(),
config: map[string]string{"ttl": "not_a_number"},
},
}
for _, tt := range successTests {
t.Run(tt.name, func(t *testing.T) {
signer, close, err := provider.New(tt.ctx, tt.config)
if err != nil {
t.Fatalf("Test %q failed: expected no error, but got: %v", tt.name, err)
}
if signer == nil {
t.Fatalf("Test %q failed: signer instance should not be nil", tt.name)
}
if close != nil {
if err := close(); err != nil {
t.Fatalf("Cleanup function returned an error: %v", err)
}
}
})
}
}
// TestSignerProviderFailure verifies failure scenarios for SignerProvider.
func TestSignerProviderFailure(t *testing.T) {
provider := SignerProvider{}
failureTests := []struct {
name string
ctx context.Context
config map[string]string
wantErr bool
}{
{
name: "Nil Context",
ctx: nil,
config: map[string]string{},
wantErr: true,
},
}
for _, tt := range failureTests {
t.Run(tt.name, func(t *testing.T) {
signerInstance, close, err := provider.New(tt.ctx, tt.config)
if (err != nil) != tt.wantErr {
t.Fatalf("Test %q failed: expected error: %v, got: %v", tt.name, tt.wantErr, err)
}
if signerInstance != nil {
t.Fatalf("Test %q failed: expected signer instance to be nil", tt.name)
}
if close != nil {
t.Fatalf("Test %q failed: expected cleanup function to be nil", tt.name)
}
})
}
}

View File

@@ -0,0 +1,73 @@
package signer
import (
"context"
"crypto/ed25519"
"encoding/base64"
"errors"
"fmt"
"golang.org/x/crypto/blake2b"
)
// Config holds the configuration for the signing process.
type Config struct {
}
// Signer implements the Signer interface and handles the signing process.
type Signer struct {
config *Config
}
// New creates a new Signer instance with the given configuration.
func New(ctx context.Context, config *Config) (*Signer, func() error, error) {
s := &Signer{config: config}
return s, nil, nil
}
// hash generates a signing string using BLAKE-512 hashing.
func hash(payload []byte, createdAt, expiresAt int64) (string, error) {
hasher, _ := blake2b.New512(nil)
_, err := hasher.Write(payload)
if err != nil {
return "", fmt.Errorf("failed to hash payload: %w", err)
}
hashSum := hasher.Sum(nil)
digestB64 := base64.StdEncoding.EncodeToString(hashSum)
return fmt.Sprintf("(created): %d\n(expires): %d\ndigest: BLAKE-512=%s", createdAt, expiresAt, digestB64), nil
}
// generateSignature signs the given signing string using the provided private key.
func generateSignature(signingString []byte, privateKeyBase64 string) ([]byte, error) {
privateKeyBytes, err := base64.StdEncoding.DecodeString(privateKeyBase64)
if err != nil {
return nil, fmt.Errorf("error decoding private key: %w", err)
}
if len(privateKeyBytes) != ed25519.SeedSize {
return nil, errors.New("invalid seed length")
}
// Generate the private key from the seed
privateKey := ed25519.NewKeyFromSeed(privateKeyBytes)
return ed25519.Sign(privateKey, signingString), nil
}
// Sign generates a digital signature for the provided payload.
func (s *Signer) Sign(ctx context.Context, body []byte, privateKeyBase64 string, createdAt, expiresAt int64) (string, error) {
signingString, err := hash(body, createdAt, expiresAt)
if err != nil {
return "", err
}
signature, err := generateSignature([]byte(signingString), privateKeyBase64)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(signature), nil
}

View File

@@ -0,0 +1,104 @@
package signer
import (
"context"
"crypto/ed25519"
"encoding/base64"
"strings"
"testing"
"time"
)
// generateTestKeys generates a test private and public key pair in base64 encoding.
func generateTestKeys() (string, string) {
publicKey, privateKey, _ := ed25519.GenerateKey(nil)
return base64.StdEncoding.EncodeToString(privateKey.Seed()), base64.StdEncoding.EncodeToString(publicKey)
}
// TestSignSuccess tests the Sign method with valid inputs to ensure it produces a valid signature.
func TestSignSuccess(t *testing.T) {
privateKey, _ := generateTestKeys()
config := Config{}
signer, close, _ := New(context.Background(), &config)
successTests := []struct {
name string
payload []byte
privateKey string
createdAt int64
expiresAt int64
}{
{
name: "Valid Signing",
payload: []byte("test payload"),
privateKey: privateKey,
createdAt: time.Now().Unix(),
expiresAt: time.Now().Unix() + 3600,
},
}
for _, tt := range successTests {
t.Run(tt.name, func(t *testing.T) {
signature, err := signer.Sign(context.Background(), tt.payload, tt.privateKey, tt.createdAt, tt.expiresAt)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if len(signature) == 0 {
t.Errorf("expected a non-empty signature, but got empty")
}
if close != nil {
if err := close(); err != nil {
t.Fatalf("Cleanup function returned an error: %v", err)
}
}
})
}
}
// TestSignFailure tests the Sign method with invalid inputs to ensure proper error handling.
func TestSignFailure(t *testing.T) {
config := Config{}
signer, close, _ := New(context.Background(), &config)
failureTests := []struct {
name string
payload []byte
privateKey string
createdAt int64
expiresAt int64
expectErrString string
}{
{
name: "Invalid Private Key",
payload: []byte("test payload"),
privateKey: "invalid_key",
createdAt: time.Now().Unix(),
expiresAt: time.Now().Unix() + 3600,
expectErrString: "error decoding private key",
},
{
name: "Short Private Key",
payload: []byte("test payload"),
privateKey: base64.StdEncoding.EncodeToString([]byte("short_key")),
createdAt: time.Now().Unix(),
expiresAt: time.Now().Unix() + 3600,
expectErrString: "invalid seed length",
},
}
for _, tt := range failureTests {
t.Run(tt.name, func(t *testing.T) {
_, err := signer.Sign(context.Background(), tt.payload, tt.privateKey, tt.createdAt, tt.expiresAt)
if err == nil {
t.Errorf("expected error but got none")
} else if !strings.Contains(err.Error(), tt.expectErrString) {
t.Errorf("expected error message to contain %q, got %v", tt.expectErrString, err)
}
if close != nil {
if err := close(); err != nil {
t.Fatalf("Cleanup function returned an error: %v", err)
}
}
})
}
}

View File

@@ -0,0 +1,24 @@
package main
import (
"context"
"errors"
"github.com/beckn/beckn-onix/pkg/plugin/definition"
"github.com/beckn/beckn-onix/pkg/plugin/implementation/signvalidator"
)
// provider provides instances of Verifier.
type provider struct{}
// New initializes a new Verifier instance.
func (vp provider) New(ctx context.Context, config map[string]string) (definition.SignValidator, func() error, error) {
if ctx == nil {
return nil, nil, errors.New("context cannot be nil")
}
return signvalidator.New(ctx, &signvalidator.Config{})
}
// Provider is the exported symbol that the plugin manager will look for.
var Provider = provider{}

View File

@@ -0,0 +1,89 @@
package main
import (
"context"
"testing"
)
// TestVerifierProviderSuccess tests successful creation of a verifier.
func TestVerifierProviderSuccess(t *testing.T) {
provider := provider{}
tests := []struct {
name string
ctx context.Context
config map[string]string
}{
{
name: "Successful creation",
ctx: context.Background(),
config: map[string]string{},
},
{
name: "Nil context",
ctx: context.TODO(),
config: map[string]string{},
},
{
name: "Empty config",
ctx: context.Background(),
config: map[string]string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
verifier, close, err := provider.New(tt.ctx, tt.config)
if err != nil {
t.Fatalf("Expected no error, but got: %v", err)
}
if verifier == nil {
t.Fatal("Expected verifier instance to be non-nil")
}
if close != nil {
if err := close(); err != nil {
t.Fatalf("Test %q failed: cleanup function returned an error: %v", tt.name, err)
}
}
})
}
}
// TestVerifierProviderFailure tests cases where verifier creation should fail.
func TestVerifierProviderFailure(t *testing.T) {
provider := provider{}
tests := []struct {
name string
ctx context.Context
config map[string]string
wantErr bool
}{
{
name: "Nil context failure",
ctx: nil,
config: map[string]string{},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
verifierInstance, close, err := provider.New(tt.ctx, tt.config)
if (err != nil) != tt.wantErr {
t.Fatalf("Expected error: %v, but got: %v", tt.wantErr, err)
}
if verifierInstance != nil {
t.Fatal("Expected verifier instance to be nil")
}
if close != nil {
if err := close(); err != nil {
t.Fatalf("Test %q failed: cleanup function returned an error: %v", tt.name, err)
}
}
})
}
}

View File

@@ -0,0 +1,110 @@
package signvalidator
import (
"context"
"crypto/ed25519"
"encoding/base64"
"fmt"
"strconv"
"strings"
"time"
"github.com/beckn/beckn-onix/pkg/model"
"golang.org/x/crypto/blake2b"
)
// Config struct for Verifier.
type Config struct {
}
// validator implements the validator interface.
type validator struct {
config *Config
}
// New creates a new Verifier instance.
func New(ctx context.Context, config *Config) (*validator, func() error, error) {
v := &validator{config: config}
return v, nil, nil
}
// Verify checks the signature for the given payload and public key.
func (v *validator) Validate(ctx context.Context, body []byte, header string, publicKeyBase64 string) error {
createdTimestamp, expiredTimestamp, signature, err := parseAuthHeader(header)
if err != nil {
return model.NewSignValidationErr(fmt.Errorf("error parsing header: %w", err))
}
signatureBytes, err := base64.StdEncoding.DecodeString(signature)
if err != nil {
return fmt.Errorf("error decoding signature: %w", err)
}
currentTime := time.Now().Unix()
if createdTimestamp > currentTime || currentTime > expiredTimestamp {
return model.NewSignValidationErr(fmt.Errorf("signature is expired or not yet valid"))
}
createdTime := time.Unix(createdTimestamp, 0)
expiredTime := time.Unix(expiredTimestamp, 0)
signingString := hash(body, createdTime.Unix(), expiredTime.Unix())
decodedPublicKey, err := base64.StdEncoding.DecodeString(publicKeyBase64)
if err != nil {
return model.NewSignValidationErr(fmt.Errorf("error decoding public key: %w", err))
}
if !ed25519.Verify(ed25519.PublicKey(decodedPublicKey), []byte(signingString), signatureBytes) {
return model.NewSignValidationErr(fmt.Errorf("signature verification failed"))
}
return nil
}
// parseAuthHeader extracts signature values from the Authorization header.
func parseAuthHeader(header string) (int64, int64, string, error) {
header = strings.TrimPrefix(header, "Signature ")
parts := strings.Split(header, ",")
signatureMap := make(map[string]string)
for _, part := range parts {
keyValue := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(keyValue) == 2 {
key := strings.TrimSpace(keyValue[0])
value := strings.Trim(keyValue[1], "\"")
signatureMap[key] = value
}
}
createdTimestamp, err := strconv.ParseInt(signatureMap["created"], 10, 64)
if err != nil {
// TODO: Return appropriate error code when Error Code Handling Module is ready
return 0, 0, "", fmt.Errorf("invalid created timestamp: %w", err)
}
expiredTimestamp, err := strconv.ParseInt(signatureMap["expires"], 10, 64)
if err != nil {
return 0, 0, "", model.NewSignValidationErr(fmt.Errorf("invalid expires timestamp: %w", err))
}
signature := signatureMap["signature"]
if signature == "" {
// TODO: Return appropriate error code when Error Code Handling Module is ready
return 0, 0, "", model.NewSignValidationErr(fmt.Errorf("signature missing in header"))
}
return createdTimestamp, expiredTimestamp, signature, nil
}
// hash constructs a signing string for verification.
func hash(payload []byte, createdTimestamp, expiredTimestamp int64) string {
hasher, _ := blake2b.New512(nil)
hasher.Write(payload)
hashSum := hasher.Sum(nil)
digestB64 := base64.StdEncoding.EncodeToString(hashSum)
return fmt.Sprintf("(created): %d\n(expires): %d\ndigest: BLAKE-512=%s", createdTimestamp, expiredTimestamp, digestB64)
}

View File

@@ -0,0 +1,147 @@
package signvalidator
import (
"context"
"crypto/ed25519"
"encoding/base64"
"strconv"
"testing"
"time"
)
// generateTestKeyPair generates a new ED25519 key pair for testing.
func generateTestKeyPair() (string, string) {
publicKey, privateKey, _ := ed25519.GenerateKey(nil)
return base64.StdEncoding.EncodeToString(privateKey), base64.StdEncoding.EncodeToString(publicKey)
}
// signTestData creates a valid signature for test cases.
func signTestData(privateKeyBase64 string, body []byte, createdAt, expiresAt int64) string {
privateKeyBytes, _ := base64.StdEncoding.DecodeString(privateKeyBase64)
privateKey := ed25519.PrivateKey(privateKeyBytes)
signingString := hash(body, createdAt, expiresAt)
signature := ed25519.Sign(privateKey, []byte(signingString))
return base64.StdEncoding.EncodeToString(signature)
}
// TestVerifySuccessCases tests all valid signature verification cases.
func TestVerifySuccess(t *testing.T) {
privateKeyBase64, publicKeyBase64 := generateTestKeyPair()
tests := []struct {
name string
body []byte
createdAt int64
expiresAt int64
}{
{
name: "Valid Signature",
body: []byte("Test Payload"),
createdAt: time.Now().Unix(),
expiresAt: time.Now().Unix() + 3600,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
signature := signTestData(privateKeyBase64, tt.body, tt.createdAt, tt.expiresAt)
header := `Signature created="` + strconv.FormatInt(tt.createdAt, 10) +
`", expires="` + strconv.FormatInt(tt.expiresAt, 10) +
`", signature="` + signature + `"`
verifier, close, _ := New(context.Background(), &Config{})
err := verifier.Validate(context.Background(), tt.body, header, publicKeyBase64)
if err != nil {
t.Fatalf("Expected no error, but got: %v", err)
}
if close != nil {
if err := close(); err != nil {
t.Fatalf("Test %q failed: cleanup function returned an error: %v", tt.name, err)
}
}
})
}
}
// TestVerifyFailureCases tests all invalid signature verification cases.
func TestVerifyFailure(t *testing.T) {
privateKeyBase64, publicKeyBase64 := generateTestKeyPair()
_, wrongPublicKeyBase64 := generateTestKeyPair()
tests := []struct {
name string
body []byte
header string
pubKey string
}{
{
name: "Missing Authorization Header",
body: []byte("Test Payload"),
header: "",
pubKey: publicKeyBase64,
},
{
name: "Malformed Header",
body: []byte("Test Payload"),
header: `InvalidSignature created="wrong"`,
pubKey: publicKeyBase64,
},
{
name: "Invalid Base64 Signature",
body: []byte("Test Payload"),
header: `Signature created="` + strconv.FormatInt(time.Now().Unix(), 10) +
`", expires="` + strconv.FormatInt(time.Now().Unix()+3600, 10) +
`", signature="!!INVALIDBASE64!!"`,
pubKey: publicKeyBase64,
},
{
name: "Expired Signature",
body: []byte("Test Payload"),
header: `Signature created="` + strconv.FormatInt(time.Now().Unix()-7200, 10) +
`", expires="` + strconv.FormatInt(time.Now().Unix()-3600, 10) +
`", signature="` + signTestData(privateKeyBase64, []byte("Test Payload"), time.Now().Unix()-7200, time.Now().Unix()-3600) + `"`,
pubKey: publicKeyBase64,
},
{
name: "Invalid Public Key",
body: []byte("Test Payload"),
header: `Signature created="` + strconv.FormatInt(time.Now().Unix(), 10) +
`", expires="` + strconv.FormatInt(time.Now().Unix()+3600, 10) +
`", signature="` + signTestData(privateKeyBase64, []byte("Test Payload"), time.Now().Unix(), time.Now().Unix()+3600) + `"`,
pubKey: wrongPublicKeyBase64,
},
{
name: "Invalid Expires Timestamp",
body: []byte("Test Payload"),
header: `Signature created="` + strconv.FormatInt(time.Now().Unix(), 10) +
`", expires="invalid_timestamp"`,
pubKey: publicKeyBase64,
},
{
name: "Signature Missing in Headers",
body: []byte("Test Payload"),
header: `Signature created="` + strconv.FormatInt(time.Now().Unix(), 10) +
`", expires="` + strconv.FormatInt(time.Now().Unix()+3600, 10) + `"`,
pubKey: publicKeyBase64,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
verifier, close, _ := New(context.Background(), &Config{})
err := verifier.Validate(context.Background(), tt.body, tt.header, tt.pubKey)
if err == nil {
t.Fatal("Expected an error but got none")
}
if close != nil {
if err := close(); err != nil {
t.Fatalf("Test %q failed: cleanup function returned an error: %v", tt.name, err)
}
}
})
}
}

View File

@@ -1,12 +1,22 @@
package plugin
import (
<<<<<<< HEAD
"context"
"fmt"
=======
"archive/zip"
"context"
"fmt"
"io"
"io/fs"
"net/http"
>>>>>>> fdec61e90d57d3d82345d023c1a0d33d5a90583b
"os"
"path/filepath"
"plugin"
"strings"
<<<<<<< HEAD
"github.com/beckn/beckn-onix/pkg/plugin/definition"
@@ -142,4 +152,381 @@ func LoadConfig(path string) (*Config, error) {
}
return &cfg, nil
=======
"time"
"github.com/beckn/beckn-onix/pkg/log"
"github.com/beckn/beckn-onix/pkg/plugin/definition"
)
type onixPlugin interface {
Lookup(string) (plugin.Symbol, error)
}
// Manager is responsible for managing dynamically loaded plugins.
type Manager struct {
plugins map[string]onixPlugin // plugins holds the dynamically loaded plugins.
closers []func() // closers contains functions to release resources when the manager is closed.
}
func validateMgrCfg(cfg *ManagerConfig) error {
if cfg.Root == "" {
return fmt.Errorf("root path cannot be empty")
}
return nil
}
// NewManager initializes a new Manager instance by loading plugins from the specified configuration.
func NewManager(ctx context.Context, cfg *ManagerConfig) (*Manager, func(), error) {
if err := validateMgrCfg(cfg); err != nil {
return nil, nil, fmt.Errorf("Invalid config: %w", err)
}
log.Debugf(ctx, "RemoteRoot : %s", cfg.RemoteRoot)
if len(cfg.RemoteRoot) != 0 {
log.Debugf(ctx, "Unzipping files from : %s to : %s", cfg.RemoteRoot, cfg.Root)
if err := unzip(cfg.RemoteRoot, cfg.Root); err != nil {
return nil, nil, err
}
}
plugins, err := plugins(ctx, cfg)
if err != nil {
return nil, nil, err
}
closers := []func(){}
return &Manager{plugins: plugins, closers: closers}, func() {
for _, closer := range closers {
closer()
}
}, nil
}
func plugins(ctx context.Context, cfg *ManagerConfig) (map[string]onixPlugin, error) {
plugins := make(map[string]onixPlugin)
err := filepath.WalkDir(cfg.Root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil // Skip directories
}
if strings.HasSuffix(d.Name(), ".so") {
id := strings.TrimSuffix(d.Name(), ".so") // Extract plugin ID
p, elapsed, err := loadPlugin(ctx, path, id)
if err != nil {
return err
}
plugins[id] = p
log.Debugf(ctx, "Loaded plugin: %s in %s", id, elapsed)
}
return nil
})
if err != nil {
return nil, err
}
return plugins, nil
}
// loadPlugin attempts to load a plugin from the given path and logs the execution time.
func loadPlugin(ctx context.Context, path, id string) (onixPlugin, time.Duration, error) {
log.Debugf(ctx, "Loading plugin: %s", id)
start := time.Now()
p, err := plugin.Open(path)
if err != nil {
return nil, 0, fmt.Errorf("failed to open plugin %s: %w", id, err)
}
elapsed := time.Since(start)
return p, elapsed, nil
}
func provider[T any](plugins map[string]onixPlugin, id string) (T, error) {
var zero T
pgn, ok := plugins[id]
if !ok {
return zero, fmt.Errorf("plugin %s not found", id)
}
provider, err := pgn.Lookup("Provider")
if err != nil {
return zero, fmt.Errorf("failed to lookup Provider for %s: %w", id, err)
}
log.Debugf(context.Background(), "Provider type: %T\n", provider)
pp, ok := provider.(T)
if !ok {
return zero, fmt.Errorf("failed to cast Provider for %s", id)
}
log.Debugf(context.Background(), "Casting successful for: %s", provider)
return pp, nil
}
// Publisher returns a Publisher instance based on the provided configuration.
// It reuses the loaded provider and registers a cleanup function.
func (m *Manager) Publisher(ctx context.Context, cfg *Config) (definition.Publisher, error) {
pp, err := provider[definition.PublisherProvider](m.plugins, cfg.ID)
if err != nil {
return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err)
}
p, closer, err := pp.New(ctx, cfg.Config)
if err != nil {
return nil, err
}
if closer != nil {
m.closers = append(m.closers, func() {
if err := closer(); err != nil {
panic(err)
}
})
}
return p, nil
}
// SchemaValidator returns a SchemaValidator instance based on the provided configuration.
// It registers a cleanup function for resource management.
func (m *Manager) SchemaValidator(ctx context.Context, cfg *Config) (definition.SchemaValidator, error) {
vp, err := provider[definition.SchemaValidatorProvider](m.plugins, cfg.ID)
if err != nil {
return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err)
}
v, closer, err := vp.New(ctx, cfg.Config)
if err != nil {
return nil, err
}
if closer != nil {
m.closers = append(m.closers, func() {
if err := closer(); err != nil {
panic(err)
}
})
}
return v, nil
}
// Router returns a Router instance based on the provided configuration.
// It registers a cleanup function for resource management.
func (m *Manager) Router(ctx context.Context, cfg *Config) (definition.Router, error) {
rp, err := provider[definition.RouterProvider](m.plugins, cfg.ID)
if err != nil {
return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err)
}
router, closer, err := rp.New(ctx, cfg.Config)
if err != nil {
return nil, err
}
if closer != nil {
m.closers = append(m.closers, func() {
if err := closer(); err != nil {
panic(err)
}
})
}
return router, nil
}
// Middleware returns an HTTP middleware function based on the provided configuration.
func (m *Manager) Middleware(ctx context.Context, cfg *Config) (func(http.Handler) http.Handler, error) {
mwp, err := provider[definition.MiddlewareProvider](m.plugins, cfg.ID)
if err != nil {
return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err)
}
return mwp.New(ctx, cfg.Config)
}
// 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)
if err != nil {
return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err)
}
step, closer, error := sp.New(ctx, cfg.Config)
if closer != nil {
m.closers = append(m.closers, closer)
}
return step, error
}
// Cache returns a Cache instance based on the provided configuration.
// It registers a cleanup function for resource management.
func (m *Manager) Cache(ctx context.Context, cfg *Config) (definition.Cache, error) {
cp, err := provider[definition.CacheProvider](m.plugins, cfg.ID)
if err != nil {
return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err)
}
c, closer, err := cp.New(ctx, cfg.Config)
if err != nil {
return nil, err
}
if closer != nil {
m.closers = append(m.closers, func() {
if err := closer(); err != nil {
panic(err)
}
})
}
return c, nil
}
// Signer returns a Signer instance based on the provided configuration.
// It registers a cleanup function for resource management.
func (m *Manager) Signer(ctx context.Context, cfg *Config) (definition.Signer, error) {
sp, err := provider[definition.SignerProvider](m.plugins, cfg.ID)
if err != nil {
return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err)
}
s, closer, err := sp.New(ctx, cfg.Config)
if err != nil {
return nil, err
}
if closer != nil {
m.closers = append(m.closers, func() {
if err := closer(); err != nil {
panic(err)
}
})
}
return s, nil
}
// Encryptor returns an Encrypter instance based on the provided configuration.
// It registers a cleanup function for resource management.
func (m *Manager) Encryptor(ctx context.Context, cfg *Config) (definition.Encrypter, error) {
ep, err := provider[definition.EncrypterProvider](m.plugins, cfg.ID)
if err != nil {
return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err)
}
encrypter, closer, err := ep.New(ctx, cfg.Config)
if err != nil {
return nil, err
}
if closer != nil {
m.closers = append(m.closers, func() {
if err := closer(); err != nil {
panic(err)
}
})
}
return encrypter, nil
}
// Decryptor returns a Decrypter instance based on the provided configuration.
// It registers a cleanup function for resource management.
func (m *Manager) Decryptor(ctx context.Context, cfg *Config) (definition.Decrypter, error) {
dp, err := provider[definition.DecrypterProvider](m.plugins, cfg.ID)
if err != nil {
return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err)
}
decrypter, closer, err := dp.New(ctx, cfg.Config)
if err != nil {
return nil, err
}
if closer != nil {
m.closers = append(m.closers, func() {
if err := closer(); err != nil {
panic(err)
}
})
}
return decrypter, nil
}
// SignValidator returns a SignValidator instance based on the provided configuration.
// It registers a cleanup function for resource management.
func (m *Manager) SignValidator(ctx context.Context, cfg *Config) (definition.SignValidator, error) {
svp, err := provider[definition.SignValidatorProvider](m.plugins, cfg.ID)
if err != nil {
return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err)
}
v, closer, err := svp.New(ctx, cfg.Config)
if err != nil {
return nil, err
}
if closer != nil {
m.closers = append(m.closers, func() {
if err := closer(); err != nil {
panic(err)
}
})
}
return v, nil
}
// KeyManager returns a KeyManager instance based on the provided configuration.
// It reuses the loaded provider.
func (m *Manager) KeyManager(ctx context.Context, cache definition.Cache, rClient definition.RegistryLookup, cfg *Config) (definition.KeyManager, error) {
kmp, err := provider[definition.KeyManagerProvider](m.plugins, cfg.ID)
if err != nil {
return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err)
}
km, closer, err := kmp.New(ctx, cache, rClient, cfg.Config)
if err != nil {
return nil, err
}
if closer != nil {
m.closers = append(m.closers, func() {
if err := closer(); err != nil {
panic(err)
}
})
}
return km, nil
}
// Validator implements handler.PluginManager.
func (m *Manager) Validator(ctx context.Context, cfg *Config) (definition.SchemaValidator, error) {
panic("unimplemented")
}
// Unzip extracts a ZIP file to the specified destination
func unzip(src, dest string) error {
r, err := zip.OpenReader(src)
if err != nil {
return err
}
defer r.Close()
// Ensure the destination directory exists
if err := os.MkdirAll(dest, 0755); err != nil {
return err
}
for _, f := range r.File {
fpath := filepath.Join(dest, f.Name)
// Ensure directory exists
log.Debugf(context.Background(), "Pain : fpath: %s,filepath.Dir(fpath): %s", fpath, filepath.Dir(fpath))
if err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
return err
}
// Open the file inside the zip
srcFile, err := f.Open()
if err != nil {
return err
}
defer srcFile.Close()
// Create the destination file
dstFile, err := os.Create(fpath)
if err != nil {
return err
}
defer dstFile.Close()
// Copy file contents
if _, err := io.Copy(dstFile, srcFile); err != nil {
return err
}
}
return nil
>>>>>>> fdec61e90d57d3d82345d023c1a0d33d5a90583b
}

2495
pkg/plugin/manager_test.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,357 +0,0 @@
{
"context": {
"action": "on_init",
"bap_id": "example-bap.com",
"bap_uri": "https://example-bap.com/prod/trv10",
"bpp_id": "example-bpp.com",
"bpp_uri": "https://example-bpp.com/prod/seller",
"domain": "ONDC:TRV10",
"location": {
"city": {
"code": "std:080"
},
"country": {
"code": "IND"
}
},
"message_id": "8926b747-0362-4fcc-b795-0994a6287700",
"timestamp": "2023-12-09T14:11:32.859Z",
"transaction_id": "870782be-6757-43f1-945c-8eeaf9536259",
"ttl": "PT30S",
"version": "2.0.1"
},
"message": {
"order": {
"cancellation_terms": [
{
"cancellation_fee": {
"percentage": "0"
},
"fulfillment_state": {
"descriptor": {
"code": "RIDE_ASSIGNED"
}
},
"reason_required": true
},
{
"cancellation_fee": {
"amount": {
"currency": "INR",
"value": "30"
}
},
"fulfillment_state": {
"descriptor": {
"code": "RIDE_ENROUTE_PICKUP"
}
},
"reason_required": true
},
{
"cancellation_fee": {
"amount": {
"currency": "INR",
"value": "50"
}
},
"fulfillment_state": {
"descriptor": {
"code": "RIDE_ARRIVED_PICKUP"
}
},
"reason_required": true
},
{
"cancellation_fee": {
"percentage": "100"
},
"fulfillment_state": {
"descriptor": {
"code": "RIDE_STARTED"
}
},
"reason_required": true
}
],
"fulfillments": [
{
"id": "F1",
"customer": {
"contact": {
"phone": "9876556789"
},
"person": {
"name": "Joe Adams"
}
},
"stops": [
{
"location": {
"gps": "13.008935, 77.644408"
},
"type": "START",
"instructions": {
"short_desc": "short description of the location",
"long_desc": "long description of the location"
}
},
{
"location": {
"gps": "12.971186, 77.586812"
},
"type": "END"
}
],
"tags": [
{
"descriptor": {
"code": "ROUTE_INFO",
"name": "Route Information"
},
"display": true,
"list": [
{
"descriptor": {
"code": "ENCODED_POLYLINE",
"name": "Path"
},
"value": "_p~iF~ps|U_ulLnnqC_mqNvxq`@"
},
{
"descriptor": {
"code": "WAYPOINTS",
"name": "Waypoints"
},
"value": "[{\"gps\":\"12.909982, 77.611822\"},{\"gps\":\"12.909982,77.611822\"},{\"gps\":\"12.909982,77.611822\"},{\"gps\":\"12.909982, 77.611822\"}]"
}
]
}
],
"type": "DELIVERY",
"vehicle": {
"category": "AUTO_RICKSHAW",
"variant": "EV"
}
}
],
"items": [
{
"descriptor": {
"code": "RIDE",
"name": "Auto Ride"
},
"fulfillment_ids": [
"F1"
],
"id": "I1",
"location_ids": [
"L1",
"L3"
],
"payment_ids": [
"PA1"
],
"price": {
"currency": "INR",
"maximum_value": "176",
"minimum_value": "136",
"value": "146"
},
"tags": [
{
"descriptor": {
"code": "FARE_POLICY",
"name": "Daytime Charges"
},
"display": true,
"list": [
{
"descriptor": {
"code": "MIN_FARE"
},
"value": "30"
},
{
"descriptor": {
"code": "MIN_FARE_DISTANCE_KM"
},
"value": "2"
},
{
"descriptor": {
"code": "PER_KM_CHARGE"
},
"value": "15"
},
{
"descriptor": {
"code": "PICKUP_CHARGE"
},
"value": "10"
},
{
"descriptor": {
"code": "WAITING_CHARGE_PER_MIN"
},
"value": "2"
},
{
"descriptor": {
"code": "NIGHT_CHARGE_MULTIPLIER"
},
"value": "1.5"
},
{
"descriptor": {
"code": "NIGHT_SHIFT_START_TIME"
},
"value": "22:00:00"
},
{
"descriptor": {
"code": "NIGHT_SHIFT_END_TIME"
},
"value": "05:00:00"
}
]
},
{
"descriptor": {
"code": "INFO",
"name": "General Information"
},
"display": true,
"list": [
{
"descriptor": {
"code": "DISTANCE_TO_NEAREST_DRIVER_METER"
},
"value": "661"
},
{
"descriptor": {
"code": "ETA_TO_NEAREST_DRIVER_MIN"
},
"value": "3"
}
]
}
]
}
],
"payments": [
{
"collected_by": "BPP",
"id": "PA1",
"params": {
"bank_account_number": "xxxxxxxxxxxxxx",
"bank_code": "XXXXXXXX",
"virtual_payment_address": "9988199772@okicic"
},
"status": "NOT-PAID",
"tags": [
{
"descriptor": {
"code": "BUYER_FINDER_FEES"
},
"display": false,
"list": [
{
"descriptor": {
"code": "BUYER_FINDER_FEES_PERCENTAGE"
},
"value": "1"
}
]
},
{
"descriptor": {
"code": "SETTLEMENT_TERMS"
},
"display": false,
"list": [
{
"descriptor": {
"code": "DELAY_INTEREST"
},
"value": "5"
},
{
"descriptor": {
"code": "SETTLEMENT_TYPE"
},
"value": "UPI"
},
{
"descriptor": {
"code": "SETTLEMENT_WINDOW"
},
"value": "PT2D"
},
{
"descriptor": {
"code": "SETTLEMENT_BASIS"
},
"value": "DELIVERY"
},
{
"descriptor": {
"code": "MANDATORY_ARBITRATION"
},
"value": "true"
},
{
"descriptor": {
"code": "COURT_JURISDICTION"
},
"value": "New Delhi"
},
{
"descriptor": {
"code": "STATIC_TERMS"
},
"value": "https://example-test-bpp.com/static-terms.txt"
},
{
"descriptor": {
"code": "SETTLEMENT_AMOUNT"
},
"value": "1.46"
}
]
}
],
"type": "ON-FULFILLMENT"
}
],
"provider": {
"id": "P1"
},
"quote": {
"breakup": [
{
"price": {
"currency": "INR",
"value": "30"
},
"title": "BASE_FARE"
},
{
"price": {
"currency": "INR",
"value": "116"
},
"title": "DISTANCE_FARE"
}
],
"price": {
"currency": "INR",
"value": "146"
},
"ttl": "PT30S"
}
}
}
}

View File

@@ -1,81 +0,0 @@
{
"context": {
"action": "search",
"bap_id": "example-bap.com",
"bap_uri": "https://example-bap.com/prod/trv10",
"domain": "ONDC:TRV10",
"location": {
"city": {
"code": "std:080"
},
"country": {
"code": "IND"
}
},
"message_id": "40963dc1-e402-4f4d-ae70-7c5864ca682c",
"timestamp": "2023-12-09T13:39:56.645Z",
"transaction_id": "870782be-6757-43f1-945c-8eeaf9536259",
"ttl": "PT30S",
"version": "2.0.1"
},
"message": {
"intent": {
"fulfillment": {
"stops": [
{
"location": {
"gps": "13.008935, 77.644408"
},
"type": "START"
},
{
"location": {
"gps": "12.971186, 77.586812"
},
"type": "END"
}
]
},
"payment": {
"collected_by": "BPP",
"tags": [
{
"descriptor": {
"code": "BUYER_FINDER_FEES"
},
"display": false,
"list": [
{
"descriptor": {
"code": "BUYER_FINDER_FEES_PERCENTAGE"
},
"value": "1"
}
]
},
{
"descriptor": {
"code": "SETTLEMENT_TERMS"
},
"display": false,
"list": [
{
"descriptor": {
"code": "DELAY_INTEREST"
},
"value": "5"
},
{
"descriptor": {
"code": "STATIC_TERMS"
},
"value": "https://example-test-bap.com/static-terms.txt"
}
]
}
]
}
}
}
}

View File

@@ -1,86 +0,0 @@
{
"context": {
"action": "search",
"bap_id": "example-bap.com",
"bap_uri": "https://example-bap.com/prod/trv10",
"domain": "ONDC:TRV10",
"location": {
"city": {
"code": "std:080"
},
"country": {
"code": "IND"
}
},
"message_id": "40963dc1-e402-4f4d-ae70-7c5864ca682c",
"timestamp": "2023-12-09T13:40:21.452Z",
"transaction_id": "870782be-6757-43f1-945c-8eeaf9536259",
"ttl": "PT30S",
"version": "2.0.1"
},
"message": {
"intent": {
"fulfillment": {
"stops": [
{
"location": {
"gps": "13.008935, 77.644408"
},
"type": "START"
},
{
"location": {
"gps": "12.971186, 77.586812"
},
"type": "END"
}
]
},
"payment": {
"collected_by": "BAP",
"tags": [
{
"descriptor": {
"code": "BUYER_FINDER_FEES"
},
"display": false,
"list": [
{
"descriptor": {
"code": "BUYER_FINDER_FEES_PERCENTAGE"
},
"value": "1"
}
]
},
{
"descriptor": {
"code": "SETTLEMENT_TERMS"
},
"display": false,
"list": [
{
"descriptor": {
"code": "SETTLEMENT_WINDOW"
},
"value": "PT1D"
},
{
"descriptor": {
"code": "SETTLEMENT_BASIS"
},
"value": "DELIVERY"
},
{
"descriptor": {
"code": "STATIC_TERMS"
},
"value": "https://example-test-bap.com/static-terms.txt"
}
]
}
]
}
}
}
}

18
pkg/plugin/testdata/dummy.go vendored Normal file
View File

@@ -0,0 +1,18 @@
package main
import (
"context"
"github.com/beckn/beckn-onix/pkg/plugin/definition"
"github.com/beckn/beckn-onix/pkg/plugin/implementation/encrypter"
)
// encrypterProvider implements the definition.encrypterProvider interface.
type encrypterProvider struct{}
func (ep encrypterProvider) New(ctx context.Context, config map[string]string) (definition.Encrypter, func() error, error) {
return encrypter.New(ctx)
}
// Provider is the exported symbol that the plugin manager will look for.
var Provider = encrypterProvider{}

View File

@@ -3,150 +3,87 @@ package response
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"net/http"
"github.com/beckn/beckn-onix/pkg/log"
"github.com/beckn/beckn-onix/pkg/model"
)
type ErrorType string
const (
SchemaValidationErrorType ErrorType = "SCHEMA_VALIDATION_ERROR"
InvalidRequestErrorType ErrorType = "INVALID_REQUEST"
)
type BecknRequest struct {
Context map[string]interface{} `json:"context,omitempty"`
}
type Error struct {
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Paths string `json:"paths,omitempty"`
}
// SchemaValidationErr represents a collection of schema validation failures.
type SchemaValidationErr struct {
Errors []Error
}
// Error implements the error interface for SchemaValidationErr.
func (e *SchemaValidationErr) Error() string {
var errorMessages []string
for _, err := range e.Errors {
errorMessages = append(errorMessages, fmt.Sprintf("%s: %s", err.Paths, err.Message))
}
return strings.Join(errorMessages, "; ")
}
type Message struct {
Ack struct {
Status string `json:"status,omitempty"`
} `json:"ack,omitempty"`
Error *Error `json:"error,omitempty"`
}
type BecknResponse struct {
Context map[string]interface{} `json:"context,omitempty"`
Message Message `json:"message,omitempty"`
}
type ClientFailureBecknResponse struct {
Context map[string]interface{} `json:"context,omitempty"`
Error *Error `json:"error,omitempty"`
}
var errorMap = map[ErrorType]Error{
SchemaValidationErrorType: {
Code: "400",
Message: "Schema validation failed",
},
InvalidRequestErrorType: {
Code: "401",
Message: "Invalid request format",
},
}
var DefaultError = Error{
Code: "500",
Message: "Internal server error",
}
func Nack(ctx context.Context, tp ErrorType, paths string, body []byte) ([]byte, error) {
var req BecknRequest
if err := json.Unmarshal(body, &req); err != nil {
return nil, fmt.Errorf("failed to parse request: %w", err)
}
errorObj, ok := errorMap[tp]
if paths != "" {
errorObj.Paths = paths
}
var response BecknResponse
if !ok {
response = BecknResponse{
Context: req.Context,
Message: Message{
Ack: struct {
Status string `json:"status,omitempty"`
}{
Status: "NACK",
},
Error: &DefaultError,
},
}
} else {
response = BecknResponse{
Context: req.Context,
Message: Message{
Ack: struct {
Status string `json:"status,omitempty"`
}{
Status: "NACK",
},
Error: &errorObj,
},
}
}
return json.Marshal(response)
}
// Ack processes the incoming Beckn request, unmarshals the JSON body into a BecknRequest struct,
// and returns a JSON-encoded acknowledgment response with a status of "ACK".
// If the request body cannot be parsed, it returns an error.
func Ack(ctx context.Context, body []byte) ([]byte, error) {
var req BecknRequest
if err := json.Unmarshal(body, &req); err != nil {
return nil, fmt.Errorf("failed to parse request: %w", err)
}
response := BecknResponse{
Context: req.Context,
Message: Message{
Ack: struct {
Status string `json:"status,omitempty"`
}{
Status: "ACK",
// SendAck sends an acknowledgment response (ACK) to the client.
func SendAck(w http.ResponseWriter) {
resp := &model.Response{
Message: model.Message{
Ack: model.Ack{
Status: model.StatusACK,
},
},
}
return json.Marshal(response)
data, _ := json.Marshal(resp) //should not fail here
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, err := w.Write(data)
if err != nil {
http.Error(w, "failed to write response", http.StatusInternalServerError)
return
}
}
// HandleClientFailure processes a client failure scenario by unmarshaling the provided
// request body, determining the appropriate error response based on the given ErrorType,
// and returning the serialized response. If the ErrorType is not found in the error map,
// a default error is used.
func HandleClientFailure(ctx context.Context, tp ErrorType, body []byte) ([]byte, error) {
var req BecknRequest
if err := json.Unmarshal(body, &req); err != nil {
return nil, fmt.Errorf("failed to parse request: %w", err)
// nack sends a negative acknowledgment (NACK) response with an error message.
func nack(ctx context.Context, w http.ResponseWriter, err *model.Error, status int) {
resp := &model.Response{
Message: model.Message{
Ack: model.Ack{
Status: model.StatusNACK,
},
Error: err,
},
}
errorObj, ok := errorMap[tp]
var response ClientFailureBecknResponse
if !ok {
response = ClientFailureBecknResponse{
Context: req.Context,
Error: &DefaultError,
}
} else {
response = ClientFailureBecknResponse{
Context: req.Context,
Error: &errorObj,
}
data, _ := json.Marshal(resp) //should not fail here
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, er := w.Write(data)
if er != nil {
log.Debugf(ctx, "Error writing response: %v, MessageID: %s", er, ctx.Value(model.ContextKeyMsgID))
http.Error(w, fmt.Sprintf("Internal server error, MessageID: %s", ctx.Value(model.ContextKeyMsgID)), http.StatusInternalServerError)
return
}
}
// internalServerError generates an internal server error response.
func internalServerError(ctx context.Context) *model.Error {
return &model.Error{
Code: http.StatusText(http.StatusInternalServerError),
Message: fmt.Sprintf("Internal server error, MessageID: %s", ctx.Value(model.ContextKeyMsgID)),
}
}
// SendNack processes different types of errors and sends an appropriate NACK response.
func SendNack(ctx context.Context, w http.ResponseWriter, err error) {
var schemaErr *model.SchemaValidationErr
var signErr *model.SignValidationErr
var badReqErr *model.BadReqErr
var notFoundErr *model.NotFoundErr
switch {
case errors.As(err, &schemaErr):
nack(ctx, w, schemaErr.BecknError(), http.StatusBadRequest)
return
case errors.As(err, &signErr):
nack(ctx, w, signErr.BecknError(), http.StatusUnauthorized)
return
case errors.As(err, &badReqErr):
nack(ctx, w, badReqErr.BecknError(), http.StatusBadRequest)
return
case errors.As(err, &notFoundErr):
nack(ctx, w, notFoundErr.BecknError(), http.StatusNotFound)
return
default:
nack(ctx, w, internalServerError(ctx), http.StatusInternalServerError)
return
}
return json.Marshal(response)
}

View File

@@ -0,0 +1,241 @@
package response
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/beckn/beckn-onix/pkg/model"
)
type errorResponseWriter struct{}
// TODO: Optimize the cases by removing these
func (e *errorResponseWriter) Write([]byte) (int, error) {
return 0, errors.New("write error")
}
func (e *errorResponseWriter) WriteHeader(statusCode int) {}
func (e *errorResponseWriter) Header() http.Header {
return http.Header{}
}
func TestSendAck(t *testing.T) {
_, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err) // For tests
}
rr := httptest.NewRecorder()
SendAck(rr)
if rr.Code != http.StatusOK {
t.Errorf("wanted status code %d, got %d", http.StatusOK, rr.Code)
}
expected := `{"message":{"ack":{"status":"ACK"}}}`
if rr.Body.String() != expected {
t.Errorf("err.Error() = %s, want %s",
rr.Body.String(), expected)
}
}
func TestSendNack(t *testing.T) {
ctx := context.WithValue(context.Background(), model.ContextKeyMsgID, "123456")
tests := []struct {
name string
err error
expected string
status int
}{
{
name: "SchemaValidationErr",
err: &model.SchemaValidationErr{
Errors: []model.Error{
{Paths: "/path1", Message: "Error 1"},
{Paths: "/path2", Message: "Error 2"},
},
},
status: http.StatusBadRequest,
expected: `{"message":{"ack":{"status":"NACK"},"error":{"code":"Bad Request","paths":"/path1;/path2","message":"Error 1; Error 2"}}}`,
},
{
name: "SignValidationErr",
err: model.NewSignValidationErr(errors.New("signature invalid")),
status: http.StatusUnauthorized,
expected: `{"message":{"ack":{"status":"NACK"},"error":{"code":"Unauthorized","message":"Signature Validation Error: signature invalid"}}}`,
},
{
name: "BadReqErr",
err: model.NewBadReqErr(errors.New("bad request error")),
status: http.StatusBadRequest,
expected: `{"message":{"ack":{"status":"NACK"},"error":{"code":"Bad Request","message":"BAD Request: bad request error"}}}`,
},
{
name: "NotFoundErr",
err: model.NewNotFoundErr(errors.New("endpoint not found")),
status: http.StatusNotFound,
expected: `{"message":{"ack":{"status":"NACK"},"error":{"code":"Not Found","message":"Endpoint not found: endpoint not found"}}}`,
},
{
name: "InternalServerError",
err: errors.New("unexpected error"),
status: http.StatusInternalServerError,
expected: `{"message":{"ack":{"status":"NACK"},"error":{"code":"Internal Server Error","message":"Internal server error, MessageID: 123456"}}}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err) // For tests
}
rr := httptest.NewRecorder()
SendNack(ctx, rr, tt.err)
if rr.Code != tt.status {
t.Errorf("wanted status code %d, got %d", tt.status, rr.Code)
}
var actual map[string]interface{}
err = json.Unmarshal(rr.Body.Bytes(), &actual)
if err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
var expected map[string]interface{}
err = json.Unmarshal([]byte(tt.expected), &expected)
if err != nil {
t.Fatalf("failed to unmarshal expected response: %v", err)
}
if !compareJSON(expected, actual) {
t.Errorf("err.Error() = %s, want %s",
actual, expected)
}
})
}
}
func compareJSON(expected, actual map[string]interface{}) bool {
expectedBytes, _ := json.Marshal(expected)
actualBytes, _ := json.Marshal(actual)
return bytes.Equal(expectedBytes, actualBytes)
}
func TestSendAck_WriteError(t *testing.T) {
w := &errorResponseWriter{}
SendAck(w)
}
// Mock struct to force JSON marshalling error
type badMessage struct{}
func (b *badMessage) MarshalJSON() ([]byte, error) {
return nil, errors.New("marshal error")
}
func TestNack_1(t *testing.T) {
tests := []struct {
name string
err *model.Error
status int
expected string
useBadJSON bool
useBadWrite bool
}{
{
name: "Schema Validation Error",
err: &model.Error{
Code: "BAD_REQUEST",
Paths: "/test/path",
Message: "Invalid schema",
},
status: http.StatusBadRequest,
expected: `{"message":{"ack":{"status":"NACK"},"error":{"code":"BAD_REQUEST","paths":"/test/path","message":"Invalid schema"}}}`,
},
{
name: "Internal Server Error",
err: &model.Error{
Code: "INTERNAL_SERVER_ERROR",
Message: "Something went wrong",
},
status: http.StatusInternalServerError,
expected: `{"message":{"ack":{"status":"NACK"},"error":{"code":"INTERNAL_SERVER_ERROR","message":"Something went wrong"}}}`,
},
{
name: "JSON Marshal Error",
err: nil, // This will be overridden to cause marshaling error
status: http.StatusInternalServerError,
expected: `Internal server error, MessageID: 12345`,
useBadJSON: true,
},
{
name: "Write Error",
err: &model.Error{
Code: "WRITE_ERROR",
Message: "Failed to write response",
},
status: http.StatusInternalServerError,
expected: `Internal server error, MessageID: 12345`,
useBadWrite: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
ctx := context.WithValue(req.Context(), model.ContextKeyMsgID, "12345")
var w http.ResponseWriter
if tt.useBadWrite {
w = &errorResponseWriter{} // Simulate write error
} else {
w = httptest.NewRecorder()
}
// TODO: Fix this approach , should not be used like this.
if tt.useBadJSON {
data, _ := json.Marshal(&badMessage{})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tt.status)
_, err := w.Write(data)
if err != nil {
http.Error(w, "Failed to write response", http.StatusInternalServerError)
return
}
return
}
nack(ctx, w, tt.err, tt.status)
if !tt.useBadWrite {
recorder, ok := w.(*httptest.ResponseRecorder)
if !ok {
t.Fatal("Failed to cast response recorder")
}
if recorder.Code != tt.status {
t.Errorf("wanted status code %d, got %d", tt.status, recorder.Code)
}
body := recorder.Body.String()
if body != tt.expected {
t.Errorf("err.Error() = %s, want %s",
body, tt.expected)
}
}
})
}
}

BIN
schemas.zip Normal file

Binary file not shown.

92
test.go
View File

@@ -1,92 +0,0 @@
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"net/url"
"github.com/beckn/beckn-onix/pkg/plugin/definition"
"github.com/beckn/beckn-onix/pkg/plugin"
)
var (
manager *plugin.Manager
)
func main() {
var err error
// Load the configuration.
config, err := plugin.LoadConfig("pkg/plugin/plugin.yaml")
if err != nil {
log.Fatalf("Failed to load plugins configuration: %v", err)
}
// Initialize the plugin manager.
manager, err = plugin.NewManager(context.Background(), config)
if err != nil {
log.Fatalf("Failed to create PluginManager: %v", err)
}
// Get the validator.
validator, _, defErr := manager.SchemaValidator(context.Background())
if defErr != nil {
log.Fatalf("Failed to get validators: %v", defErr)
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
validateHandler(w, r, validator)
})
fmt.Println("Starting server on port 8084...")
err = http.ListenAndServe(":8084", nil)
if err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
func validateHandler(w http.ResponseWriter, r *http.Request, validators definition.SchemaValidator) {
if r.Method != http.MethodPost {
http.Error(w, "Invalid request method", http.StatusMethodNotAllowed)
return
}
// Extract endpoint from request URL.
requestURL := r.RequestURI
u, err := url.ParseRequestURI(requestURL)
if err != nil {
http.Error(w, "Failed to parse request URL", http.StatusBadRequest)
return
}
payloadData, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read payload data", http.StatusInternalServerError)
return
}
ctx := context.Background()
// validationErr := validators.Validate(ctx, u, payloadData)
// if validationErr != (definition.SchemaValError{}) {
// http.Error(w, fmt.Sprintf("Document validation failed: %v", validationErr), http.StatusBadRequest)
// } else if !valid {
// http.Error(w, "Document validation failed", http.StatusBadRequest)
// } else {
// w.WriteHeader(http.StatusOK)
// if _, err := w.Write([]byte("Document validation succeeded!")); err != nil {
// log.Fatalf("Failed to write response: %v", err)
// }
// }
validationErr := validators.Validate(ctx, u, payloadData)
if validationErr != nil {
// Handle other types of errors
http.Error(w, fmt.Sprintf("Schema validation failed: %v", validationErr), http.StatusBadRequest)
} else {
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte("Schema validation succeeded!")); err != nil {
log.Fatalf("Failed to write response: %v", err)
}
}
}