package audit import ( "encoding/json" "github.com/r3labs/diff/v2" "gitlab.com/uafrica/go-utils/reflection" "gitlab.com/uafrica/go-utils/string_utils" "reflect" "regexp" "strconv" "strings" ) type FieldChange struct { From interface{} `json:"change_from"` To interface{} `json:"change_to"` } func GetChanges(original interface{}, new interface{}) (map[string]interface{}, error) { changes := map[string]interface{}{} changelog, err := diff.Diff(original, new) if err != nil { return changes, err } for _, change := range changelog { if len(change.Path) == 1 { // Root object change field := ToSnakeCase(change.Path[0]) changes[field] = FieldChange{ From: change.From, To: change.To, } } else if len(change.Path) == 2 { // Child object changed // ["Account", "ID"] // 0 = Object // 1 = field objectKey := ToSnakeCase(change.Path[0]) field := ToSnakeCase(change.Path[1]) existingObject, present := changes[objectKey] if present { if object, ok := existingObject.(map[string]interface{}); ok { object[field] = FieldChange{ From: change.From, To: change.To, } changes[objectKey] = object } } else { fieldChange := map[string]interface{}{ field: FieldChange{ From: change.From, To: change.To, }, } changes[objectKey] = fieldChange } } else if len(change.Path) == 3 { // Array of objects // ["Parcel", "0", "ActualWeight"] // 0 = Object // 1 = Index of object // 2 = field objectKey := ToSnakeCase(change.Path[0]) indexString := change.Path[1] index, _ := string_utils.StringToInt64(indexString) field := ToSnakeCase(change.Path[2]) arrayObject, present := changes[objectKey] if present { if arrayOfObjects, ok := arrayObject.([]map[string]interface{}); ok { if len(arrayOfObjects) > int(index) { // Add field to existing object in array object := arrayOfObjects[index] object[field] = FieldChange{ From: change.From, To: change.To, } } else { // new object, append to existing array fieldChange := map[string]interface{}{ field: FieldChange{ From: change.From, To: change.To, }, } changes[objectKey] = append(arrayOfObjects, fieldChange) } } } else { // Create array of objects fieldChange := map[string]interface{}{ field: FieldChange{ From: change.From, To: change.To, }, } changes[objectKey] = []map[string]interface{}{ fieldChange, } } } } return changes, nil } // GetAllChanges Returns the diff, structured in json, recursively // Be warned, here be dragons. Debug this first to understand how it works func GetAllChanges(original interface{}, new interface{}) (map[string]interface{}, error) { changes := map[string]interface{}{} changelog, err := diff.Diff(original, new) if err != nil { return changes, err } changesJson := "{" subArrays := map[string]string{} for _, change := range changelog { var value string // Keep track of the core value var key string // Keep track of the key/s for this value var closing string // Keep track of the correct number of closing characters for pathIndex := len(change.Path) - 1; pathIndex >= 0; pathIndex-- { // If this is the first value, it's the "deepest" value of this change set, otherwise leave this var alone // If this is set at the wrong point, other behaviour changes if pathIndex+1 == len(change.Path) { changedField := FieldChange{ From: change.From, To: change.To, } request, err := json.Marshal(changedField) if err != nil { return nil, err } value = string(request) value = value[1 : len(value)-1] } // If this "key" is integer-like, we handle it a little differently to ensure related data ends up // together and formatted as you would expect JSON arrays to look if _, convErr := strconv.Atoi(change.Path[pathIndex]); convErr == nil { positionOfSubArray := "" for sub := 0; sub <= pathIndex; sub++ { positionOfSubArray = positionOfSubArray + change.Path[sub] } // Add it to the placeholder data map used later on subArrays[positionOfSubArray] = subArrays[positionOfSubArray] + key + value + closing + "," // Don't insert the same placeholder into the json multiple times if !strings.Contains(changesJson, positionOfSubArray) { value = positionOfSubArray } else { // Make sure this value doesn't end up populated explicitly value = "" } } else { // Safety net so we don't compare to a non existant value if pathIndex < len(change.Path)-1 { // If the value this one "contains" has a integer-like key, this is probably an array if _, err = strconv.Atoi(change.Path[pathIndex+1]); err == nil { key = "\"" + ToSnakeCase(change.Path[pathIndex]) + "\": [" closing = "]" } else { key = "\"" + ToSnakeCase(change.Path[pathIndex]) + "\": {" + key closing = closing + "}" } } else { key = "\"" + ToSnakeCase(change.Path[pathIndex]) + "\": {" + key closing = closing + "}" } } } // Don't insert empty values (happens when multiple values within an array are set, see "placeholder" behavior) if value != "" { // Duplicate key prevention keySplit := strings.Split(key, ": ") lastOccurrence := 0 lastMatched := "" levelsDeep := 0 for sIndex, split := range keySplit { // Trim leading character off split if split[0:1] == "{" || split[0:1] == "[" { split = split[1:] } // The final value of keySplit might be empty ("") if split != "" { if index := strings.Index(changesJson, split); index > -1 && changesJson != "{" { // Prevent reverse traversal if index > lastOccurrence { // Prevent finding nested keys as opposed to our "change" keys opener := changesJson[index+len(split)+2 : index+len(split)+3] // Prevent a "base level" key from matching a nested key baseLevel := false if index >= 1 { containedBy := changesJson[index-1 : index] if levelsDeep == 0 && (containedBy == "{" || containedBy == "[") { baseLevel = true } } if (opener == "{" || opener == "[") && !baseLevel { lastOccurrence = index lastMatched = split levelsDeep = sIndex } } } } } // If the "key" is already present, handle it differently if lastOccurrence > 0 { // Strip parent keys that are already present key = key[strings.Index(key, lastMatched)+len(lastMatched):] if key[0:2] == ": " { key = key[2:] } // Strip the correct amount of closing tags closing = closing[:len(closing)-levelsDeep] // Find the position of this key in the master string position := strings.Index(changesJson, lastMatched) + len(lastMatched) + 3 // We're appending to an existing object, so strip the outermost wrapping layer key = key[1:] closing = closing[:len(closing)-1] // Place the value within the existing key changesJson = changesJson[:position] + key + value + closing + ", " + changesJson[position:] } else { // No value found, append it to the end changesJson = changesJson + key + value + closing + ", " } } } // Trim whitespace and strip trailing comma since we are done inserting at the back changesJson = strings.TrimSpace(changesJson) if changesJson[len(changesJson)-1:] == "," { changesJson = changesJson[:len(changesJson)-1] } changesJson = changesJson + "}" // Now we can go make sure the placeholders are populated with the data in for placeholderKey, placeholderValue := range subArrays { // Trim the trailing comma since we won't be adding any more stuff placeholderValue = strings.TrimSpace(placeholderValue) if placeholderValue[len(placeholderValue)-1:] == "," { placeholderValue = placeholderValue[:len(placeholderValue)-1] } changesJson = strings.ReplaceAll(changesJson, placeholderKey, "{"+placeholderValue+"}") } // Now, hopefully, the json parsing will pass and we have a nicely formatted dataset err = json.Unmarshal([]byte(changesJson), &changes) if err != nil { return nil, err } return changes, nil } var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") var matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])") func ToSnakeCase(str string) string { snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}") snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}") return strings.ToLower(snake) } func GetIntValue(object interface{}, key string) int64 { structValue := reflect.ValueOf(object) if structValue.Kind() == reflect.Struct { field := structValue.FieldByName(key) id := reflection.GetInt64Value(field) return id } return 0 } func GetStringValue(object interface{}, key string) string { structValue := reflect.ValueOf(object) if structValue.Kind() == reflect.Struct { field := structValue.FieldByName(key) id := reflection.GetStringValue(field) return id } return "" }