diff --git a/ees/ees.go b/ees/ees.go index 3eb163e1f..cfa0eb5c7 100644 --- a/ees/ees.go +++ b/ees/ees.go @@ -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 diff --git a/ees/ees_test.go b/ees/ees_test.go index 4bca0bea9..8a7034ab3 100644 --- a/ees/ees_test.go +++ b/ees/ees_test.go @@ -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)) } } diff --git a/engine/argees.go b/engine/argees.go index 444b8c70e..997c1c628 100644 --- a/engine/argees.go +++ b/engine/argees.go @@ -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 } diff --git a/engine/argees_test.go b/engine/argees_test.go new file mode 100644 index 000000000..0da1e4e3d --- /dev/null +++ b/engine/argees_test.go @@ -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 +*/ +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)) + } + }) +} diff --git a/engine/dynamicdp.go b/engine/dynamicdp.go index 2ce3947c6..293a2ddb5 100644 --- a/engine/dynamicdp.go +++ b/engine/dynamicdp.go @@ -19,6 +19,7 @@ along with this program. If not, see 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) } diff --git a/general_tests/ees_it_test.go b/general_tests/ees_it_test.go index e84a07875..764e3c41e 100644 --- a/general_tests/ees_it_test.go +++ b/general_tests/ees_it_test.go @@ -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) + } + }) }