Add action type *dynamic_route

This commit is contained in:
arberkatellari
2025-05-29 16:53:25 +02:00
committed by Dan Christian Bogos
parent ea5ed9eaad
commit 7bf0d2d162
13 changed files with 397 additions and 30 deletions

View File

@@ -129,7 +129,7 @@ func newActionConnCfg(source, action string, cfg *config.CGRConfig) ActionConnCf
utils.MetaDynamicThreshold, utils.MetaDynamicStats,
utils.MetaDynamicAttribute, utils.MetaDynamicActionPlan,
utils.MetaDynamicAction, utils.MetaDynamicDestination,
utils.MetaDynamicFilter,
utils.MetaDynamicFilter, utils.MetaDynamicRoute,
}
act := ActionConnCfg{}
switch source {
@@ -196,6 +196,7 @@ func init() {
actionFuncMap[utils.MetaDynamicAction] = dynamicAction
actionFuncMap[utils.MetaDynamicDestination] = dynamicDestination
actionFuncMap[utils.MetaDynamicFilter] = dynamicFilter
actionFuncMap[utils.MetaDynamicRoute] = dynamicRoute
}
func getActionFunc(typ string) (f actionTypeFunc, exists bool) {
@@ -2155,3 +2156,141 @@ func dynamicFilter(_ *Account, act *Action, _ Actions, _ *FilterS, ev any,
var reply string
return connMgr.Call(context.Background(), connCfg.ConnIDs, utils.APIerSv1SetFilter, fltr, &reply)
}
// dynamicRoute processes the `ExtraParameters` field from the action to
// construct a RouteProfile
//
// The ExtraParameters field format is expected as follows:
//
// 0 Tenant: string
// 1 ID: string
// 2 FilterIDs: strings separated by "&".
// 3 ActivationInterval: strings separated by "&".
// 4 Sorting: string
// 5 SortingParameters: strings separated by "&".
// 6 RouteID: string
// 7 RouteFilterIDs: strings separated by "&".
// 8 RouteAccountIDs: strings separated by "&".
// 9 RouteRatingPlanIDs: strings separated by "&".
// 10 RouteResourceIDs: strings separated by "&".
// 11 RouteStatIDs: strings separated by "&".
// 12 RouteWeight: string
// 13 RouteBlocker: string
// 14 RouteParameters: string
// 15 Weight: string
// 16 APIOpts: set of key-value pairs (separated by "&").
//
// Parameters are separated by ";" and must be provided in the specified order.
func dynamicRoute(_ *Account, act *Action, _ Actions, _ *FilterS, ev any,
_ SharedActionsData, connCfg ActionConnCfg) (err error) {
cgrEv, canCast := ev.(*utils.CGREvent)
if !canCast {
return errors.New("Couldn't cast event to CGREvent")
}
dP := utils.MapStorage{ // create DataProvider from event
utils.MetaReq: cgrEv.Event,
utils.MetaTenant: cgrEv.Tenant,
utils.MetaNow: time.Now(),
utils.MetaOpts: cgrEv.APIOpts,
}
// Parse action parameters based on the predefined format.
params := strings.Split(act.ExtraParameters, utils.InfieldSep)
if len(params) != 17 {
return errors.New(fmt.Sprintf("invalid number of parameters <%d> expected 17", len(params)))
}
// parse dynamic parameters
for i := range params {
if params[i], err = utils.ParseParamForDataProvider(params[i], dP, false); err != nil {
return err
}
}
// Prepare request arguments based on provided parameters.
route := &RouteWithAPIOpts{
RouteProfile: &RouteProfile{
Tenant: params[0],
ID: params[1],
ActivationInterval: &utils.ActivationInterval{}, // avoid reaching inside a nil pointer
Sorting: params[4],
Routes: []*Route{
{
ID: params[6],
RouteParameters: params[14],
},
},
},
APIOpts: make(map[string]any),
}
// populate Route's FilterIDs
if params[2] != utils.EmptyString {
route.FilterIDs = strings.Split(params[2], utils.ANDSep)
}
// populate Route's ActivationInterval
aISplit := strings.Split(params[3], utils.ANDSep)
if len(aISplit) > 2 {
return utils.ErrUnsupportedFormat
}
if len(aISplit) > 0 && aISplit[0] != utils.EmptyString {
if err := route.ActivationInterval.ActivationTime.UnmarshalText([]byte(aISplit[0])); err != nil {
return err
}
if len(aISplit) == 2 {
if err := route.ActivationInterval.ExpiryTime.UnmarshalText([]byte(aISplit[1])); err != nil {
return err
}
}
}
// populate Route's SortingParameters
if params[5] != utils.EmptyString {
route.SortingParameters = strings.Split(params[5], utils.ANDSep)
}
// populate Route's RouteFilterIDs
if params[7] != utils.EmptyString {
route.Routes[0].FilterIDs = strings.Split(params[7], utils.ANDSep)
}
// populate Route's RouteAccountIDs
if params[8] != utils.EmptyString {
route.Routes[0].AccountIDs = strings.Split(params[8], utils.ANDSep)
}
// populate Route's RouteRatingPlanIDs
if params[9] != utils.EmptyString {
route.Routes[0].RatingPlanIDs = strings.Split(params[9], utils.ANDSep)
}
// populate Route's RouteResourceIDs
if params[10] != utils.EmptyString {
route.Routes[0].ResourceIDs = strings.Split(params[10], utils.ANDSep)
}
// populate Route's RouteStatIDs
if params[11] != utils.EmptyString {
route.Routes[0].StatIDs = strings.Split(params[11], utils.ANDSep)
}
// populate Route's RouteWeight
if params[12] != utils.EmptyString {
route.Routes[0].Weight, err = strconv.ParseFloat(params[12], 64)
if err != nil {
return err
}
}
// populate Route's RouteBlocker
if params[13] != utils.EmptyString {
route.Routes[0].Blocker, err = strconv.ParseBool(params[13])
if err != nil {
return err
}
}
// populate Route's Weight
if params[15] != utils.EmptyString {
route.Weight, err = strconv.ParseFloat(params[15], 64)
if err != nil {
return err
}
}
// populate Route's APIOpts
if params[16] != utils.EmptyString {
if err := parseParamStringToMap(params[16], route.APIOpts); err != nil {
return err
}
}
// create the RouteProfile based on the populated parameters
var reply string
return connMgr.Call(context.Background(), connCfg.ConnIDs, utils.APIerSv1SetRouteProfile, route, &reply)
}

View File

@@ -6246,3 +6246,233 @@ func TestDynamicFilter(t *testing.T) {
})
}
}
func TestDynamicRoute(t *testing.T) {
tempConn := connMgr
tmpDm := dm
tmpCache := Cache
defer func() {
config.SetCgrConfig(config.NewDefaultCGRConfig())
SetConnManager(tempConn)
dm = tmpDm
Cache = tmpCache
}()
Cache.Clear(nil)
var rwo *RouteWithAPIOpts
ccMock := &ccMock{
calls: map[string]func(ctx *context.Context, args any, reply any) error{
utils.APIerSv1SetRouteProfile: func(ctx *context.Context, args, reply any) error {
var canCast bool
if rwo, canCast = args.(*RouteWithAPIOpts); !canCast {
return fmt.Errorf("couldnt cast")
}
return nil
},
},
}
connID := utils.ConcatenatedKey(utils.MetaInternal, utils.MetaApier)
clientconn := make(chan birpc.ClientConnector, 1)
clientconn <- ccMock
NewConnManager(config.NewDefaultCGRConfig(), map[string]chan birpc.ClientConnector{
connID: clientconn,
})
testcases := []struct {
name string
extraParams string
connIDs []string
expRwo *RouteWithAPIOpts
expectedErr string
}{
{
name: "SuccessfulRequest",
connIDs: []string{connID},
expRwo: &RouteWithAPIOpts{
RouteProfile: &RouteProfile{
Tenant: "cgrates.org",
ID: "RTP_ACNT_1001",
FilterIDs: []string{"*string:~*req.Account:1001", "*string:~*req.Account:1002"},
ActivationInterval: &utils.ActivationInterval{
ActivationTime: time.Date(2014, 7, 29, 15, 0, 0, 0, time.UTC),
},
SortingParameters: []string{"*acd", "*tcc"},
Routes: []*Route{
{
ID: "route1",
FilterIDs: []string{"*string:~*req.Account:1001", "*string:~*req.Account:1002"},
AccountIDs: []string{"1001", "1002"},
RatingPlanIDs: []string{"RP1", "RP2"},
ResourceIDs: []string{"RS1", "RS2"},
StatIDs: []string{"Stat_1", "Stat_1_1"},
Weight: 10,
Blocker: true,
RouteParameters: "param",
},
},
Sorting: utils.MetaWeight,
Weight: 10,
},
APIOpts: map[string]any{
"key": "value",
},
},
extraParams: "cgrates.org;RTP_ACNT_1001;*string:~*req.Account:1001&*string:~*req.Account:1002;2014-07-29T15:00:00Z;*weight;*acd&*tcc;route1;*string:~*req.Account:1001&*string:~*req.Account:1002;1001&1002;RP1&RP2;RS1&RS2;Stat_1&Stat_1_1;10;true;param;10;key:value",
},
{
name: "SuccessfulRequestWithDynamicPaths",
connIDs: []string{connID},
expRwo: &RouteWithAPIOpts{
RouteProfile: &RouteProfile{
Tenant: "cgrates.org",
ID: "RTP_ACNT_1001",
FilterIDs: []string{"*string:~*req.Account:1001", "*string:~*req.Account:1002"},
ActivationInterval: &utils.ActivationInterval{
ActivationTime: time.Now(),
ExpiryTime: time.Date(3000, 7, 29, 15, 0, 0, 0, time.UTC),
},
SortingParameters: []string{"*acd", "*tcc"},
Routes: []*Route{
{
ID: "route1",
FilterIDs: []string{"*string:~*req.Account:1001", "*string:~*req.Account:1002"},
AccountIDs: []string{"1001", "1002"},
RatingPlanIDs: []string{"RP1", "RP2"},
ResourceIDs: []string{"RS1", "RS2"},
StatIDs: []string{"Stat_1", "Stat_1_1"},
Weight: 10,
Blocker: true,
RouteParameters: "param",
},
},
Sorting: utils.MetaWeight,
Weight: 10,
},
APIOpts: map[string]any{
"key": "value",
},
},
extraParams: "*tenant;RTP_ACNT_<~*req.Account>;*string:~*req.Account:<~*req.Account>&*string:~*req.Account:1002;*now&3000-07-29T15:00:00Z;*weight;*acd&*tcc;route1;*string:~*req.Account:<~*req.Account>&*string:~*req.Account:1002;<~*req.Account>&1002;RP1&RP2;RS1&RS2;Stat_1&Stat_1_1;10;true;param;10;key:value",
},
{
name: "SuccessfulRequestEmptyFields",
connIDs: []string{connID},
expRwo: &RouteWithAPIOpts{
RouteProfile: &RouteProfile{
Tenant: "cgrates.org",
ID: "RTP_ACNT_1001",
FilterIDs: nil,
ActivationInterval: &utils.ActivationInterval{},
SortingParameters: nil,
Routes: []*Route{
{
ID: "route1",
FilterIDs: nil,
AccountIDs: nil,
RatingPlanIDs: nil,
ResourceIDs: nil,
StatIDs: nil,
Weight: 0,
Blocker: false,
RouteParameters: "",
},
},
Sorting: "",
Weight: 0,
},
APIOpts: map[string]any{},
},
extraParams: "cgrates.org;RTP_ACNT_1001;;;;;route1;;;;;;;;;;",
},
{
name: "MissingConns",
extraParams: "cgrates.org;RTP_ACNT_1001;*string:~*req.Account:1001&*string:~*req.Account:1002;2014-07-29T15:00:00Z;*weight;*acd&*tcc;route1;*string:~*req.Account:1001&*string:~*req.Account:1002;1001&1002;RP1&RP2;RS1&RS2;Stat_1&Stat_1_1;10;true;param;10;key:value",
expectedErr: "MANDATORY_IE_MISSING: [connIDs]",
},
{
name: "WrongNumberOfParams",
extraParams: "tenant;RTP1;;",
expectedErr: "invalid number of parameters <4> expected 17",
},
{
name: "ActivationIntervalLengthFail",
extraParams: "cgrates.org;RTP_ACNT_1001;*string:~*req.Account:1001&*string:~*req.Account:1002;2014-07-29T15:00:00Z&&;*weight;*acd&*tcc;route1;*string:~*req.Account:1001&*string:~*req.Account:1002;1001&1002;RP1&RP2;RS1&RS2;Stat_1&Stat_1_1;10;true;param;10;key:value",
expectedErr: utils.ErrUnsupportedFormat.Error(),
},
{
name: "ActivationIntervalBadStringFail",
extraParams: "cgrates.org;RTP_ACNT_1001;*string:~*req.Account:1001&*string:~*req.Account:1002;bad String;*weight;*acd&*tcc;route1;*string:~*req.Account:1001&*string:~*req.Account:1002;1001&1002;RP1&RP2;RS1&RS2;Stat_1&Stat_1_1;10;true;param;10;key:value",
expectedErr: `parsing time "bad String" as "2006-01-02T15:04:05Z07:00": cannot parse "bad String" as "2006"`,
},
{
name: "ActivationIntervalBadStringFail2",
extraParams: "cgrates.org;RTP_ACNT_1001;*string:~*req.Account:1001&*string:~*req.Account:1002;2014-07-29T15:00:00Z&bad String;*weight;*acd&*tcc;route1;*string:~*req.Account:1001&*string:~*req.Account:1002;1001&1002;RP1&RP2;RS1&RS2;Stat_1&Stat_1_1;10;true;param;10;key:value",
expectedErr: `parsing time "bad String" as "2006-01-02T15:04:05Z07:00": cannot parse "bad String" as "2006"`,
},
{
name: "RouteWeightFail",
extraParams: "cgrates.org;RTP_ACNT_1001;*string:~*req.Account:1001&*string:~*req.Account:1002;2014-07-29T15:00:00Z;*weight;*acd&*tcc;route1;*string:~*req.Account:1001&*string:~*req.Account:1002;1001&1002;RP1&RP2;RS1&RS2;Stat_1&Stat_1_1;BadString;true;param;10;key:value",
expectedErr: `strconv.ParseFloat: parsing "BadString": invalid syntax`,
},
{
name: "RouteBlockerFail",
extraParams: "cgrates.org;RTP_ACNT_1001;*string:~*req.Account:1001&*string:~*req.Account:1002;2014-07-29T15:00:00Z;*weight;*acd&*tcc;route1;*string:~*req.Account:1001&*string:~*req.Account:1002;1001&1002;RP1&RP2;RS1&RS2;Stat_1&Stat_1_1;10;BadString;param;10;key:value",
expectedErr: `strconv.ParseBool: parsing "BadString": invalid syntax`,
},
{
name: "WeightFail",
extraParams: "cgrates.org;RTP_ACNT_1001;*string:~*req.Account:1001&*string:~*req.Account:1002;2014-07-29T15:00:00Z;*weight;*acd&*tcc;route1;*string:~*req.Account:1001&*string:~*req.Account:1002;1001&1002;RP1&RP2;RS1&RS2;Stat_1&Stat_1_1;10;true;param;BadString;key:value",
expectedErr: `strconv.ParseFloat: parsing "BadString": invalid syntax`,
},
{
name: "InvalidOptsMap",
extraParams: "cgrates.org;RTP_ACNT_1001;*string:~*req.Account:1001&*string:~*req.Account:1002;2014-07-29T15:00:00Z;*weight;*acd&*tcc;route1;*string:~*req.Account:1001&*string:~*req.Account:1002;1001&1002;RP1&RP2;RS1&RS2;Stat_1&Stat_1_1;10;true;param;10;opt",
expectedErr: "invalid key-value pair: opt",
},
}
for i, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
action := &Action{ExtraParameters: tc.extraParams}
ev := &utils.CGREvent{
Tenant: "cgrates.org",
ID: "evID",
Time: &time.Time{},
Event: map[string]any{
utils.AccountField: "1001",
},
}
t.Cleanup(func() {
rwo = nil
})
err := dynamicRoute(nil, action, nil, nil, ev,
SharedActionsData{}, ActionConnCfg{
ConnIDs: tc.connIDs,
})
if tc.expectedErr != "" {
if err == nil || err.Error() != tc.expectedErr {
t.Errorf("expected error <%v>, received <%v>", tc.expectedErr, err)
}
} else if err != nil {
t.Error(err)
} else if !reflect.DeepEqual(rwo, tc.expRwo) {
if i != 1 {
t.Errorf("Expected <%v>\nReceived\n<%v>", utils.ToJSON(tc.expRwo), utils.ToJSON(rwo))
} else {
// Get the absolute difference between the times
diff := rwo.ActivationInterval.ActivationTime.Sub(tc.expRwo.ActivationInterval.ActivationTime)
if diff < 0 {
diff = -diff // Make sure it's positive
}
// Check if difference is less than or equal to 1 second
if diff <= time.Second {
tc.expRwo.ActivationInterval.ActivationTime = rwo.ActivationInterval.ActivationTime
if !reflect.DeepEqual(rwo, tc.expRwo) {
t.Errorf("Expected <%v>\nReceived\n<%v>", utils.ToJSON(tc.expRwo), utils.ToJSON(rwo))
}
} else {
t.Errorf("Expected <%v>\nReceived\n<%v>", utils.ToJSON(tc.expRwo), utils.ToJSON(rwo))
}
}
}
})
}
}

View File

@@ -90,6 +90,11 @@ func (r *Route) Clone() *Route {
return clone
}
type RouteWithAPIOpts struct {
*RouteProfile
APIOpts map[string]any
}
// RouteProfile represents the configuration of a Route profile
type RouteProfile struct {
Tenant string