package configutil import ( "encoding/json" "strings" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" ) // MergeProtoJSON applies a JSON object as a partial protobuf update while // retaining fields omitted by the caller. func MergeProtoJSON(target proto.Message, patch json.RawMessage, options protojson.UnmarshalOptions) error { currentRaw, err := protojson.MarshalOptions{UseProtoNames: false}.Marshal(target) if err != nil { return err } var current map[string]any if err = json.Unmarshal(currentRaw, ¤t); err != nil { return err } var incoming map[string]any if err = json.Unmarshal(patch, &incoming); err != nil { return err } incoming = normalizeJSONKeys(incoming).(map[string]any) mergeJSONObjects(current, incoming) merged, err := json.Marshal(current) if err != nil { return err } return options.Unmarshal(merged, target) } func normalizeJSONKeys(value any) any { switch item := value.(type) { case map[string]any: result := make(map[string]any, len(item)) for key, nested := range item { result[snakeToLowerCamel(key)] = normalizeJSONKeys(nested) } return result case []any: result := make([]any, len(item)) for index, nested := range item { result[index] = normalizeJSONKeys(nested) } return result default: return value } } func snakeToLowerCamel(value string) string { if !strings.Contains(value, "_") { return value } parts := strings.Split(value, "_") result := parts[0] for _, part := range parts[1:] { if part != "" { result += strings.ToUpper(part[:1]) + part[1:] } } return result } func mergeJSONObjects(target, patch map[string]any) { for key, value := range patch { if incoming, ok := value.(map[string]any); ok { if existing, exists := target[key].(map[string]any); exists { mergeJSONObjects(existing, incoming) continue } } target[key] = value } }