165 lines
3.4 KiB
Go
165 lines
3.4 KiB
Go
package history
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidPatch = errors.New("invalid patch")
|
|
ErrConflict = errors.New("history conflict")
|
|
ErrNotFound = errors.New("history entity not found")
|
|
)
|
|
|
|
func canonicalJSON(v any) ([]byte, error) {
|
|
return json.Marshal(v)
|
|
}
|
|
|
|
func hashCanonical(v any) (string, []byte, error) {
|
|
b, err := canonicalJSON(v)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
sum := sha256.Sum256(b)
|
|
return hex.EncodeToString(sum[:]), b, nil
|
|
}
|
|
|
|
func toGenericMap(v any) (map[string]any, error) {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(b, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func fromGenericMap(m map[string]any, dest any) error {
|
|
normalizeMapForCanonical(m)
|
|
b, err := json.Marshal(m)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(b, dest)
|
|
}
|
|
|
|
func normalizeMapForCanonical(v any) {
|
|
switch t := v.(type) {
|
|
case map[string]any:
|
|
for k, vv := range t {
|
|
if s, ok := vv.(string); ok && strings.TrimSpace(s) == "" {
|
|
t[k] = nil
|
|
continue
|
|
}
|
|
normalizeMapForCanonical(vv)
|
|
}
|
|
case []any:
|
|
for i := range t {
|
|
normalizeMapForCanonical(t[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func applyPatch(state map[string]any, ops []PatchOp) error {
|
|
for _, op := range ops {
|
|
if err := applyPatchOp(state, op); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyPatchOp(root map[string]any, op PatchOp) error {
|
|
if root == nil {
|
|
return fmt.Errorf("%w: nil root", ErrInvalidPatch)
|
|
}
|
|
kind := strings.ToLower(strings.TrimSpace(op.Op))
|
|
if kind != "add" && kind != "replace" && kind != "remove" {
|
|
return fmt.Errorf("%w: unsupported op %q", ErrInvalidPatch, op.Op)
|
|
}
|
|
if !strings.HasPrefix(op.Path, "/") || op.Path == "/" {
|
|
return fmt.Errorf("%w: invalid path %q", ErrInvalidPatch, op.Path)
|
|
}
|
|
parts := parsePointer(op.Path)
|
|
if len(parts) == 0 {
|
|
return fmt.Errorf("%w: invalid path %q", ErrInvalidPatch, op.Path)
|
|
}
|
|
for _, p := range parts {
|
|
if p == "" {
|
|
return fmt.Errorf("%w: empty segment in %q", ErrInvalidPatch, op.Path)
|
|
}
|
|
if isNumeric(p) {
|
|
return fmt.Errorf("%w: array paths not supported in v1 (%q)", ErrInvalidPatch, op.Path)
|
|
}
|
|
}
|
|
|
|
parent := root
|
|
for i := 0; i < len(parts)-1; i++ {
|
|
segment := parts[i]
|
|
next, ok := parent[segment]
|
|
if !ok || next == nil {
|
|
if kind == "remove" {
|
|
return nil
|
|
}
|
|
child := map[string]any{}
|
|
parent[segment] = child
|
|
parent = child
|
|
continue
|
|
}
|
|
child, ok := next.(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("%w: non-object segment %q", ErrInvalidPatch, segment)
|
|
}
|
|
parent = child
|
|
}
|
|
|
|
last := parts[len(parts)-1]
|
|
switch kind {
|
|
case "remove":
|
|
delete(parent, last)
|
|
return nil
|
|
case "add", "replace":
|
|
parent[last] = op.Value
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("%w: unsupported op %q", ErrInvalidPatch, op.Op)
|
|
}
|
|
}
|
|
|
|
func parsePointer(path string) []string {
|
|
raw := strings.Split(strings.TrimPrefix(path, "/"), "/")
|
|
out := make([]string, 0, len(raw))
|
|
for _, p := range raw {
|
|
p = strings.ReplaceAll(p, "~1", "/")
|
|
p = strings.ReplaceAll(p, "~0", "~")
|
|
out = append(out, p)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isNumeric(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for _, r := range s {
|
|
if r < '0' || r > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func sortedStrings(values []string) []string {
|
|
out := append([]string(nil), values...)
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|