Fix YAML patch in some cases #1626

This commit is contained in:
Alex X
2025-03-07 21:44:23 +03:00
parent 39c14e6556
commit a15deedf0d
6 changed files with 237 additions and 248 deletions

View File

@@ -18,7 +18,7 @@ func LoadConfig(v any) {
} }
} }
func PatchConfig(key string, value any, path ...string) error { func PatchConfig(path []string, value any) error {
if ConfigPath == "" { if ConfigPath == "" {
return errors.New("config file disabled") return errors.New("config file disabled")
} }
@@ -26,7 +26,7 @@ func PatchConfig(key string, value any, path ...string) error {
// empty config is OK // empty config is OK
b, _ := os.ReadFile(ConfigPath) b, _ := os.ReadFile(ConfigPath)
b, err := yaml.Patch(b, key, value, path...) b, err := yaml.Patch(b, path, value)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -103,7 +103,7 @@ func apiPair(id, url string) error {
streams.New(id, conn.URL()) streams.New(id, conn.URL())
return app.PatchConfig(id, conn.URL(), "streams") return app.PatchConfig([]string{"streams", id}, conn.URL())
} }
func apiUnpair(id string) error { func apiUnpair(id string) error {
@@ -123,7 +123,7 @@ func apiUnpair(id string) error {
streams.Delete(id) streams.Delete(id)
return app.PatchConfig(id, nil, "streams") return app.PatchConfig([]string{"streams", id}, nil)
} }
func findHomeKitURLs() map[string]*url.URL { func findHomeKitURLs() map[string]*url.URL {

View File

@@ -222,7 +222,7 @@ func (s *server) DelPair(conn net.Conn, id string) {
} }
func (s *server) PatchConfig() { func (s *server) PatchConfig() {
if err := app.PatchConfig("pairings", s.pairings, "homekit", s.stream); err != nil { if err := app.PatchConfig([]string{"homekit", s.stream, "pairings"}, s.pairings); err != nil {
log.Error().Err(err).Msgf( log.Error().Err(err).Msgf(
"[homekit] can't save %s pairings=%v", s.stream, s.pairings, "[homekit] can't save %s pairings=%v", s.stream, s.pairings,
) )

View File

@@ -53,7 +53,7 @@ func apiStreams(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := app.PatchConfig(name, query["src"], "streams"); err != nil { if err := app.PatchConfig([]string{"streams", name}, query["src"]); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
} }
@@ -96,7 +96,7 @@ func apiStreams(w http.ResponseWriter, r *http.Request) {
case "DELETE": case "DELETE":
delete(streams, src) delete(streams, src)
if err := app.PatchConfig(src, nil, "streams"); err != nil { if err := app.PatchConfig([]string{"streams", src}, nil); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
} }
} }

View File

@@ -23,149 +23,157 @@ func Encode(v any, indent int) ([]byte, error) {
return b.Bytes(), nil return b.Bytes(), nil
} }
// Patch - change key/value pair in YAML file without break formatting func Patch(in []byte, path []string, value any) ([]byte, error) {
func Patch(src []byte, key string, value any, path ...string) ([]byte, error) { out, err := patch(in, path, value)
nodeParent, err := FindParent(src, path...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var dst []byte // validate
if err = yaml.Unmarshal(out, map[string]any{}); err != nil {
if nodeParent != nil {
dst, err = AddOrReplace(src, key, value, nodeParent)
} else {
dst, err = AddToEnd(src, key, value, path...)
}
if err = yaml.Unmarshal(dst, map[string]any{}); err != nil {
return nil, err return nil, err
} }
return dst, nil return out, nil
} }
// FindParent - return YAML Node from path of keys (tree) func patch(in []byte, path []string, value any) ([]byte, error) {
func FindParent(src []byte, path ...string) (*yaml.Node, error) {
if len(src) == 0 {
return nil, nil
}
var root yaml.Node var root yaml.Node
if err := yaml.Unmarshal(src, &root); err != nil { if err := yaml.Unmarshal(in, &root); err != nil {
// invalid yaml
return nil, err return nil, err
} }
if root.Content == nil { // empty in
return nil, nil if len(root.Content) != 1 {
return addToEnd(in, path, value)
} }
parent := root.Content[0] // yaml.DocumentNode // yaml is not dict
for _, name := range path { if root.Content[0].Kind != yaml.MappingNode {
if parent == nil { return nil, errors.New("yaml: can't patch")
break
}
_, parent = FindChild(parent, name)
} }
return parent, nil
// dict items list
nodes := root.Content[0].Content
n := len(path) - 1
// parent node key/value
pKey, pVal := findNode(nodes, path[:n])
if pKey == nil {
// no parent node
return addToEnd(in, path, value)
}
var paste []byte
if value != nil {
// nil value means delete key
var err error
v := map[string]any{path[n]: value}
if paste, err = Encode(v, 2); err != nil {
return nil, err
}
}
iKey, _ := findNode(pVal.Content, path[n:])
if iKey != nil {
// key item not nil (replace value)
paste = addIndent(paste, iKey.Column-1)
i0, i1 := nodeBounds(in, iKey)
return join(in[:i0], paste, in[i1:]), nil
}
if pVal.Content != nil {
// parent value not nil (use first child indent)
paste = addIndent(paste, pVal.Column-1)
} else {
// parent value is nil (use parent indent + 2)
paste = addIndent(paste, pKey.Column+1)
}
_, i1 := nodeBounds(in, pKey)
return join(in[:i1], paste, in[i1:]), nil
} }
// FindChild - search and return YAML key/value pair for current Node func findNode(nodes []*yaml.Node, keys []string) (key, value *yaml.Node) {
func FindChild(node *yaml.Node, name string) (key, value *yaml.Node) { for i, name := range keys {
for i, child := range node.Content { for j := 0; j < len(nodes); j += 2 {
if child.Value != name { if nodes[j].Value == name {
continue if i < len(keys)-1 {
nodes = nodes[j+1].Content
break
}
return nodes[j], nodes[j+1]
}
} }
return child, node.Content[i+1]
} }
return nil, nil return nil, nil
} }
func FirstChild(node *yaml.Node) *yaml.Node { func nodeBounds(in []byte, node *yaml.Node) (offset0, offset1 int) {
if node.Content == nil { // start from next line after node
return node offset0 = lineOffset(in, node.Line)
} offset1 = lineOffset(in, node.Line+1)
return node.Content[0]
}
func LastChild(node *yaml.Node) *yaml.Node { if offset1 < 0 {
if node.Content == nil { return offset0, len(in)
return node
}
return LastChild(node.Content[len(node.Content)-1])
}
func AddOrReplace(src []byte, key string, value any, nodeParent *yaml.Node) ([]byte, error) {
v := map[string]any{key: value}
put, err := Encode(v, 2)
if err != nil {
return nil, err
} }
if nodeKey, nodeValue := FindChild(nodeParent, key); nodeKey != nil { for i := offset1; i < len(in); {
put = AddIndent(put, nodeKey.Column-1) indent, length := parseLine(in[i:])
if indent+1 != length {
i0 := LineOffset(src, nodeKey.Line) if node.Column < indent+1 {
i1 := LineOffset(src, LastChild(nodeValue).Line+1) offset1 = i + length
} else {
if i1 < 0 { // no new line on the end of file break
if value != nil {
return append(src[:i0], put...), nil
} }
return src[:i0], nil
} }
i += length
dst := make([]byte, 0, len(src)+len(put))
dst = append(dst, src[:i0]...)
if value != nil {
dst = append(dst, put...)
}
return append(dst, src[i1:]...), nil
} }
put = AddIndent(put, FirstChild(nodeParent).Column-1) return
i := LineOffset(src, LastChild(nodeParent).Line+1)
if i < 0 { // no new line on the end of file
src = append(src, '\n')
if value != nil {
src = append(src, put...)
}
return src, nil
}
dst := make([]byte, 0, len(src)+len(put))
dst = append(dst, src[:i]...)
if value != nil {
dst = append(dst, put...)
}
return append(dst, src[i:]...), nil
} }
func AddToEnd(src []byte, key string, value any, path ...string) ([]byte, error) { func addToEnd(in []byte, path []string, value any) ([]byte, error) {
if len(path) > 1 || value == nil { if len(path) != 2 || value == nil {
return nil, errors.New("config: path not exist") return nil, errors.New("yaml: path not exist")
} }
v := map[string]map[string]any{ v := map[string]map[string]any{
path[0]: {key: value}, path[0]: {path[1]: value},
} }
put, err := Encode(v, 2) paste, err := Encode(v, 2)
if err != nil { if err != nil {
return nil, err return nil, err
} }
dst := make([]byte, 0, len(src)+len(put)+10) return join(in, paste), nil
dst = append(dst, src...)
if l := len(src); l > 0 && src[l-1] != '\n' {
dst = append(dst, '\n')
}
return append(dst, put...), nil
} }
func AddPrefix(src, pre []byte) (dst []byte) { func join(items ...[]byte) []byte {
n := len(items) - 1
for _, b := range items {
n += len(b)
}
buf := make([]byte, 0, n)
for _, b := range items {
if len(b) == 0 {
continue
}
if n = len(buf); n > 0 && buf[n-1] != '\n' {
buf = append(buf, '\n')
}
buf = append(buf, b...)
}
return buf
}
func addPrefix(src, pre []byte) (dst []byte) {
for len(src) > 0 { for len(src) > 0 {
dst = append(dst, pre...) dst = append(dst, pre...)
i := bytes.IndexByte(src, '\n') + 1 i := bytes.IndexByte(src, '\n') + 1
@@ -180,21 +188,21 @@ func AddPrefix(src, pre []byte) (dst []byte) {
return return
} }
func AddIndent(src []byte, indent int) (dst []byte) { func addIndent(in []byte, indent int) (dst []byte) {
pre := make([]byte, indent) pre := make([]byte, indent)
for i := 0; i < indent; i++ { for i := 0; i < indent; i++ {
pre[i] = ' ' pre[i] = ' '
} }
return AddPrefix(src, pre) return addPrefix(in, pre)
} }
func LineOffset(b []byte, line int) (offset int) { func lineOffset(in []byte, line int) (offset int) {
for l := 1; ; l++ { for l := 1; ; l++ {
if l == line { if l == line {
return offset return offset
} }
i := bytes.IndexByte(b[offset:], '\n') + 1 i := bytes.IndexByte(in[offset:], '\n') + 1
if i == 0 { if i == 0 {
break break
} }
@@ -202,3 +210,21 @@ func LineOffset(b []byte, line int) (offset int) {
} }
return -1 return -1
} }
func parseLine(b []byte) (indent int, length int) {
prefix := true
for ; length < len(b); length++ {
switch b[length] {
case ' ':
if prefix {
indent++
}
case '\n':
length++
return
default:
prefix = false
}
}
return
}

View File

@@ -7,140 +7,103 @@ import (
) )
func TestPatch(t *testing.T) { func TestPatch(t *testing.T) {
b := []byte(`# prefix`) tests := []struct {
name string
// 1. Add first src string
b, err := Patch(b, "camera1", "url1", "streams") path []string
require.Nil(t, err) value any
expect string
require.Equal(t, `# prefix }{
streams: {
camera1: url1 name: "empty config",
`, string(b)) src: "",
path: []string{"streams", "camera1"},
// 2. Add second value: "val1",
b, err = Patch(b, "camera2", []string{"url2", "url3"}, "streams") expect: "streams:\n camera1: val1\n",
require.Nil(t, err) },
{
require.Equal(t, `# prefix name: "empty main key",
streams: src: "#dummy",
camera1: url1 path: []string{"streams", "camera1"},
camera2: value: "val1",
- url2 expect: "#dummy\nstreams:\n camera1: val1\n",
- url3 },
`, string(b)) {
name: "single line value",
// 3. Replace first src: "streams:\n camera1: url1\n camera2: url2",
b, err = Patch(b, "camera1", "url4", "streams") path: []string{"streams", "camera1"},
require.Nil(t, err) value: "val1",
expect: "streams:\n camera1: val1\n camera2: url2",
require.Equal(t, `# prefix },
streams: {
camera1: url4 name: "next line value",
camera2: src: "streams:\n camera1:\n url1\n camera2: url2",
- url2 path: []string{"streams", "camera1"},
- url3 value: "val1",
`, string(b)) expect: "streams:\n camera1: val1\n camera2: url2",
},
// 4. Replace second {
b, err = Patch(b, "camera2", "url5", "streams") name: "two lines value",
require.Nil(t, err) src: "streams:\n camera1: url1\n url2\n camera2: url2",
path: []string{"streams", "camera1"},
require.Equal(t, `# prefix value: "val1",
streams: expect: "streams:\n camera1: val1\n camera2: url2",
camera1: url4 },
camera2: url5 {
`, string(b)) name: "next two lines value",
src: "streams:\n camera1:\n url1\n url2\n camera2: url2",
// 5. Delete first path: []string{"streams", "camera1"},
b, err = Patch(b, "camera1", nil, "streams") value: "val1",
require.Nil(t, err) expect: "streams:\n camera1: val1\n camera2: url2",
},
require.Equal(t, `# prefix {
streams: name: "add array",
camera2: url5 src: "",
`, string(b)) path: []string{"streams", "camera1"},
} value: []string{"val1", "val2"},
expect: "streams:\n camera1:\n - val1\n - val2\n",
func TestPatchParings(t *testing.T) { },
b := []byte(`homekit: {
camera1: name: "remove value",
pin: 123-45-678 src: "streams:\n camera1: url1\n camera2: url2",
streams: path: []string{"streams", "camera1"},
camera1: url1 value: nil,
`) expect: "streams:\n camera2: url2",
},
// 1. Add new key {
pairings := []string{"client1", "client2"} name: "add pairings",
src: "homekit:\n camera1:\nstreams:\n camera1: url1",
b, err := Patch(b, "pairings", pairings, "homekit", "camera1") path: []string{"homekit", "camera1", "pairings"},
require.Nil(t, err) value: []string{"val1"},
expect: "homekit:\n camera1:\n pairings:\n - val1\nstreams:\n camera1: url1",
require.Equal(t, `homekit: },
camera1: {
pin: 123-45-678 name: "remove pairings",
pairings: src: "homekit:\n camera1:\n pairings:\n - val1\nstreams:\n camera1: url1",
- client1 path: []string{"homekit", "camera1", "pairings"},
- client2 value: nil,
streams: expect: "homekit:\n camera1:\nstreams:\n camera1: url1",
camera1: url1 },
`, string(b)) {
} name: "no new line",
src: "streams:\n camera1: url1",
func TestPatch2(t *testing.T) { path: []string{"streams", "camera1"},
b := []byte(`streams: value: "val1",
camera1: expect: "streams:\n camera1: val1\n",
- url1 },
- url2 {
`) name: "no new line",
src: "streams:\n camera1: url1\nhomekit:\n camera1:\n name: dummy",
b, err := Patch(b, "camera2", "url3", "streams") path: []string{"homekit", "camera1", "pairings"},
require.Nil(t, err) value: []string{"val1"},
expect: "streams:\n camera1: url1\nhomekit:\n camera1:\n name: dummy\n pairings:\n - val1\n",
require.Equal(t, `streams: },
camera1: }
- url1 for _, tt := range tests {
- url2 t.Run(tt.name, func(t *testing.T) {
camera2: url3 b, err := Patch([]byte(tt.src), tt.path, tt.value)
`, string(b)) require.NoError(t, err)
} require.Equal(t, tt.expect, string(b))
})
func TestNoNewLineEnd1(t *testing.T) { }
b := []byte(`streams:
camera1: url4
camera2:
- url2
- url3`)
b, err := Patch(b, "camera2", "url5", "streams")
require.Nil(t, err)
require.Equal(t, `streams:
camera1: url4
camera2: url5
`, string(b))
}
func TestNoNewLineEnd2(t *testing.T) {
b := []byte(`streams:
camera1: url1
homekit:
camera1:
pin: 123-45-678`)
// 1. Add new key
pairings := []string{"client1", "client2"}
b, err := Patch(b, "pairings", pairings, "homekit", "camera1")
require.Nil(t, err)
require.Equal(t, `streams:
camera1: url1
homekit:
camera1:
pin: 123-45-678
pairings:
- client1
- client2
`, string(b))
} }