Ensure CostDetails is of *EventCost type when parsing dynamicDP

CGREventWithEeIDs has also been optimized and properly tested. Comments
have been added explaining the process.

When sending a request to AttributeS from EEs, CostDetails from the reply
will now be overwritten by the original CostDetails to preserve its type.
The downside is that we are assuming that CostDetails was not altered by
AttributeS. We might consider adding a type check against *engine.EventCost
to at least stay backwards compatible with *gob and *internal connections.

general_tests/ees_it_test.go has been updated to ensure changes are working
properly.
This commit is contained in:
ionutboangiu
2024-03-01 18:15:56 -05:00
committed by Dan Christian Bogos
parent 221f6e2c91
commit 49d6b8d565
6 changed files with 319 additions and 40 deletions

View File

@@ -116,6 +116,14 @@ func (eeS *EventExporterS) attrSProcessEvent(cgrEv *utils.CGREvent, attrIDs []st
return nil, err
}
if len(rplyEv.AlteredFields) != 0 {
// Restore original CostDetails in reply to preserve its type, which is lost after
// calling AttributeS via a *json connection. Assumes AttributeS does not modify
// CostDetails. If it does, we should probably add a type check against *engine.EventCost
// before to stay backwards compatible with *gob and *internal connections.
if _, has := cgrEv.Event[utils.CostDetails]; has {
rplyEv.Event[utils.CostDetails] = cgrEv.Event[utils.CostDetails]
}
return rplyEv.CGREvent, nil
}
return cgrEv, nil

View File

@@ -103,7 +103,9 @@ func TestAttrSProcessEvent(t *testing.T) {
utils.AttributeSv1ProcessEvent: func(args, reply any) error {
rplyEv := &engine.AttrSProcessEventReply{
AlteredFields: []string{"testcase"},
CGREvent: &utils.CGREvent{Event: map[string]any{"testcase": 1}},
CGREvent: &utils.CGREvent{
Event: map[string]any{"testcase": 1},
},
}
*reply.(*engine.AttrSProcessEventReply) = *rplyEv
return nil
@@ -131,7 +133,7 @@ func TestAttrSProcessEvent(t *testing.T) {
if rplyEv, err := eeS.attrSProcessEvent(cgrEv, []string{}, utils.EmptyString); err != nil {
t.Error(err)
} else if !reflect.DeepEqual(exp, rplyEv) {
t.Errorf("Expected %v but received %v", utils.ToJSON(exp), utils.ToJSON(cgrEv))
t.Errorf("Expected %v but received %v", utils.ToJSON(exp), utils.ToJSON(rplyEv))
}
}

View File

@@ -25,9 +25,11 @@ import (
"github.com/cgrates/cgrates/utils"
)
// CGREventWithEeIDs struct is moved in engine due to importing ciclying packages in order to unmarshalling properly for our EventCost type. This is the API struct argument
// CGREventWithEeIDs is CGREvent with EventExporterIDs
// CGREventWithEeIDs is CGREvent with EventExporterIDs. This
// struct is used as an API argument. It has been moved into
// the engine package to avoid import cycling issues that were
// encountered when trying to properly handle unmarshalling
// for our EventCost type.
type CGREventWithEeIDs struct {
EeIDs []string
*utils.CGREvent
@@ -54,49 +56,62 @@ func (attr *CGREventWithEeIDs) RPCClone() (any, error) {
return attr.Clone(), nil
}
func (cgr *CGREventWithEeIDs) UnmarshalJSON(data []byte) (err error) {
// firstly, we will unamrshall the entire data into raw bytes
ids := make(map[string]json.RawMessage)
if err = json.Unmarshal(data, &ids); err != nil {
return
// UnmarshalJSON decodes the JSON data into a CGREventWithEeIDs, while
// ensuring that the CostDetails key of the embedded CGREvent is of
// type *engine.EventCost.
func (cgr *CGREventWithEeIDs) UnmarshalJSON(data []byte) error {
// Define a temporary struct with the same
// structure as CGREventWithEeIDs.
var temp struct {
EeIDs []string
*utils.CGREvent
}
// populate eeids in case of it's existance
eeIDs := make([]string, len(ids[utils.EeIDs]))
if err = json.Unmarshal(ids[utils.EeIDs], &eeIDs); err != nil {
return
// Unmarshal JSON data into the temporary struct,
// using the default unmarshaler.
err := json.Unmarshal(data, &temp)
if err != nil {
return err
}
cgr.EeIDs = eeIDs
// populate the entire CGRevent struct in case of it's existance
var cgrEv *utils.CGREvent
if err = json.Unmarshal(data, &cgrEv); err != nil {
return
}
cgr.CGREvent = cgrEv
// check if we have CostDetails and modify it's type (by default it was map[string]any by unrmarshaling, now it will be EventCost)
if ecEv, has := cgrEv.Event[utils.CostDetails]; has {
var bts []byte
switch ecEv.(type) {
if ecEv, has := temp.Event[utils.CostDetails]; has {
var ecBytes []byte
// CostDetails value can either be a JSON string (which is
// the marshaled form of an EventCost) or a map representing
// the EventCost directly.
switch v := ecEv.(type) {
case string:
btsToStr, err := json.Marshal(ecEv)
if err != nil {
return err
}
var toString string
if err = json.Unmarshal(btsToStr, &toString); err != nil {
return err
}
bts = []byte(toString)
// If string, it's assumed to be the JSON
// representation of EventCost.
ecBytes = []byte(v)
default:
bts, err = json.Marshal(ecEv)
// Otherwise we assume it's a map and we marshal
// it back to JSON to prepare for unmarshalling
// into EventCost.
ecBytes, err = json.Marshal(v)
if err != nil {
return err
}
}
ec := new(EventCost)
if err = json.Unmarshal(bts, &ec); err != nil {
// Unmarshal the JSON (either directly from the string case
// or from the marshaled map) into an EventCost struct.
var ec EventCost
if err := json.Unmarshal(ecBytes, &ec); err != nil {
return err
}
cgr.Event[utils.CostDetails] = ec
// Update the Event map with the unmarshalled EventCost,
// ensuring the type of CostDetails is *EventCost.
temp.Event[utils.CostDetails] = &ec
}
return
// Assign the extracted EeIDs and CGREvent
// to the main struct fields.
cgr.EeIDs = temp.EeIDs
cgr.CGREvent = temp.CGREvent
return nil
}

130
engine/argees_test.go Normal file
View File

@@ -0,0 +1,130 @@
/*
Real-time Online/Offline Charging System (OCS) for Telecom & ISP environments
Copyright (C) ITsysCOM GmbH
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>
*/
package engine
import (
"encoding/json"
"fmt"
"reflect"
"testing"
"time"
"github.com/cgrates/cgrates/utils"
)
func TestArgEEsUnmarshalJSON(t *testing.T) {
var testEC = &EventCost{
CGRID: "164b0422fdc6a5117031b427439482c6a4f90e41",
RunID: utils.MetaDefault,
StartTime: time.Date(2017, 1, 9, 16, 18, 21, 0, time.UTC),
AccountSummary: &AccountSummary{
Tenant: "cgrates.org",
ID: "dan",
BalanceSummaries: []*BalanceSummary{
{
UUID: "8c54a9e9-d610-4c82-bcb5-a315b9a65010",
ID: "BALANCE_1",
Type: utils.MetaMonetary,
Value: 50,
Initial: 60,
Disabled: false,
},
},
AllowNegative: false,
Disabled: false,
},
}
cdBytes, err := json.Marshal(testEC)
if err != nil {
t.Fatal(err)
}
cgrEvWithIDs := CGREventWithEeIDs{
EeIDs: []string{"id1", "id2"},
CGREvent: &utils.CGREvent{
Tenant: "cgrates.org",
ID: "ev1",
Event: map[string]any{
utils.AccountField: "1001",
},
},
}
t.Run("UnmarshalFromMap", func(t *testing.T) {
var cdMap map[string]any
if err = json.Unmarshal(cdBytes, &cdMap); err != nil {
t.Fatal(err)
}
cgrEvWithIDs.Event[utils.CostDetails] = cdMap
cgrEvBytes, err := json.Marshal(cgrEvWithIDs)
if err != nil {
t.Fatal(err)
}
var rcvCGREv CGREventWithEeIDs
if err = json.Unmarshal(cgrEvBytes, &rcvCGREv); err != nil {
t.Fatal(err)
}
expectedType := "*engine.EventCost"
if cdType := fmt.Sprintf("%T", rcvCGREv.Event[utils.CostDetails]); cdType != expectedType {
t.Fatalf("expected type to be %v, received %v", expectedType, cdType)
}
cgrEvWithIDs.Event[utils.CostDetails] = testEC
if !reflect.DeepEqual(rcvCGREv, cgrEvWithIDs) {
t.Errorf("expected: %v,\nreceived: %v",
utils.ToJSON(cgrEvWithIDs), utils.ToJSON(rcvCGREv))
}
})
t.Run("UnmarshalFromString", func(t *testing.T) {
cdStringBytes, err := json.Marshal(string(cdBytes))
if err != nil {
t.Fatal(err)
}
var cdString string
if err = json.Unmarshal(cdStringBytes, &cdString); err != nil {
t.Fatal(err)
}
cgrEvWithIDs.Event[utils.CostDetails] = cdString
cgrEvBytes, err := json.Marshal(cgrEvWithIDs)
if err != nil {
t.Fatal(err)
}
var rcvCGREv CGREventWithEeIDs
if err = json.Unmarshal(cgrEvBytes, &rcvCGREv); err != nil {
t.Fatal(err)
}
expectedType := "*engine.EventCost"
if cdType := fmt.Sprintf("%T", rcvCGREv.Event[utils.CostDetails]); cdType != expectedType {
t.Fatalf("expected type to be %v, received %v", expectedType, cdType)
}
cgrEvWithIDs.Event[utils.CostDetails] = testEC
if !reflect.DeepEqual(rcvCGREv, cgrEvWithIDs) {
t.Errorf("expected: %v,\nreceived: %v",
utils.ToJSON(cgrEvWithIDs), utils.ToJSON(rcvCGREv))
}
})
}

View File

@@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>
package engine
import (
"encoding/json"
"fmt"
"github.com/nyaruka/phonenumbers"
@@ -71,6 +72,45 @@ func (dDP *dynamicDP) FieldAsInterface(fldPath []string) (val any, err error) {
if len(fldPath) == 0 {
return nil, utils.ErrNotFound
}
// Parsing deeper than the first level in *req.CostDetails requires it to be
// of type *EventCost. If not, we serialize and deserialize into an *EventCost.
if len(fldPath) > 3 &&
fldPath[0] == utils.MetaReq && fldPath[1] == utils.CostDetails {
if mp, canCast := dDP.initialDP.(utils.MapStorage); canCast {
if event, canCast := mp[utils.MetaReq].(map[string]any); canCast {
if cd, has := event[utils.CostDetails]; has {
var cdBytes []byte
switch cd := cd.(type) {
case *EventCost:
// Directly proceed if already *EventCost.
return cd.FieldAsInterface(fldPath[2:])
case string:
// Convert string to bytes for unmarshalling
// if it's a serialized *EventCost.
cdBytes = []byte(cd)
default:
// Marshal non-string types to JSON bytes
// for unmarshalling into *EventCost.
cdBytes, err = json.Marshal(cd)
if err != nil {
return nil, err
}
}
var ec EventCost
if err = json.Unmarshal(cdBytes, &ec); err != nil {
return nil, err
}
// Update CostDetails with the unmarshalled *EventCost
// to avoid repetitive serialization.
event[utils.CostDetails] = &ec
return ec.FieldAsInterface(fldPath[2:])
}
}
}
}
if initialDPPrefixes.Has(fldPath[0]) {
return dDP.initialDP.FieldAsInterface(fldPath)
}

View File

@@ -51,6 +51,10 @@ func TestEEsExportEventChanges(t *testing.T) {
content := `{
"general": {
"log_level": 7
},
"data_db": {
"db_type": "*internal"
},
@@ -69,7 +73,7 @@ func TestEEsExportEventChanges(t *testing.T) {
"ees": {
"enabled": true,
"attributes_conns":["*internal"],
"attributes_conns":["*localhost"],
"exporters": [
{
"id": "exporter1",
@@ -82,6 +86,9 @@ func TestEEsExportEventChanges(t *testing.T) {
"fields":[
{"tag": "CGRID", "path": "*uch.CGRID1", "type": "*variable", "value": "~*req.CGRID"},
{"tag": "RequestType", "path": "*uch.RequestType1", "type": "*variable", "value": "~*req.RequestType"},
{"tag": "BalanceID", "path": "*uch.BalanceID", "type": "*variable", "value": "~*req.CostDetails.Charges[0].Increments[0].Accounting.Balance.ID"},
{"tag": "BalanceType", "path": "*uch.BalanceType", "type": "*variable", "value": "~*req.CostDetails.Charges[0].Increments[0].Accounting.Balance.Type"},
{"tag": "BalanceFound", "path": "*uch.BalanceFound", "type": "*variable", "value": "~*req.BalanceFound"},
{"tag": "ExporterID", "path": "*uch.ExporterID1", "type": "*variable", "value": "~*opts.*exporterID"}
],
},
@@ -136,6 +143,19 @@ func TestEEsExportEventChanges(t *testing.T) {
},
},
},
{
FilterIDs: []string{
"*string:~*req.CostDetails.Charges[0].Increments[0].Accounting.Balance.ID:BALANCE_TEST",
"*string:~*req.CostDetails.Charges[0].Increments[0].Accounting.Balance.Type:*voice",
},
Path: "*req.BalanceFound",
Type: utils.MetaVariable,
Value: config.RSRParsers{
&config.RSRParser{
Rules: utils.TrueStr,
},
},
},
},
Blocker: false,
Weight: 10,
@@ -157,6 +177,31 @@ func TestEEsExportEventChanges(t *testing.T) {
Event: map[string]any{
utils.CGRID: "TEST",
utils.RequestType: utils.MetaRated,
utils.CostDetails: &engine.EventCost{
Charges: []*engine.ChargingInterval{
{
Increments: []*engine.ChargingIncrement{
{
AccountingID: "ACCOUNTING_TEST",
},
},
},
},
Accounting: engine.Accounting{
"ACCOUNTING_TEST": &engine.BalanceCharge{
BalanceUUID: "123456",
},
},
AccountSummary: &engine.AccountSummary{
BalanceSummaries: engine.BalanceSummaries{
{
ID: "BALANCE_TEST",
Type: utils.MetaVoice,
UUID: "123456",
},
},
},
},
},
},
}
@@ -220,4 +265,43 @@ func TestEEsExportEventChanges(t *testing.T) {
t.Errorf("expected %v, received %v", "exporter2", exporterID2)
}
})
t.Run("CheckAttributesAlteredFields", func(t *testing.T) {
var balanceID any
if err = client.Call(context.Background(), utils.CacheSv1GetItem, &utils.ArgsGetCacheItemWithAPIOpts{
Tenant: "cgrates.org",
ArgsGetCacheItem: utils.ArgsGetCacheItem{
CacheID: utils.CacheUCH,
ItemID: "BalanceID",
},
}, &balanceID); err != nil {
t.Error(err)
} else if balanceID != "BALANCE_TEST" {
t.Errorf("expected %v, received %v", "BALANCE_TEST", balanceID)
}
var balanceType any
if err = client.Call(context.Background(), utils.CacheSv1GetItem, &utils.ArgsGetCacheItemWithAPIOpts{
Tenant: "cgrates.org",
ArgsGetCacheItem: utils.ArgsGetCacheItem{
CacheID: utils.CacheUCH,
ItemID: "BalanceType",
},
}, &balanceType); err != nil {
t.Error(err)
} else if balanceType != utils.MetaVoice {
t.Errorf("expected %v, received %v", "BALANCE_TEST", balanceType)
}
var balanceFound any
if err = client.Call(context.Background(), utils.CacheSv1GetItem, &utils.ArgsGetCacheItemWithAPIOpts{
Tenant: "cgrates.org",
ArgsGetCacheItem: utils.ArgsGetCacheItem{
CacheID: utils.CacheUCH,
ItemID: "BalanceFound",
},
}, &balanceFound); err != nil {
t.Error(err)
} else if balanceFound != "true" {
t.Errorf("expected %v, received %v", "true", balanceFound)
}
})
}