diff --git a/Taskfile.yaml b/Taskfile.yaml index b9ed62c..719ade5 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -5,5 +5,7 @@ includes: taskfile: hack/common/Taskfile_library.yaml flatten: true vars: - CODE_DIRS: '{{.ROOT_DIR}}/pkg/...' + NESTED_MODULES: api + API_DIRS: '{{.ROOT_DIR}}/api/...' + CODE_DIRS: '{{.ROOT_DIR}}/pkg/... {{.ROOT_DIR}}/api/...' GENERATE_DOCS_INDEX: "true" diff --git a/api/go.mod b/api/go.mod new file mode 100644 index 0000000..09e34d8 --- /dev/null +++ b/api/go.mod @@ -0,0 +1,27 @@ +module github.com/openmcp-project/controller-utils/api + +go 1.24.2 + +require github.com/fluxcd/pkg/apis/kustomize v1.10.0 + +require ( + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/x448/float16 v0.8.4 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/text v0.23.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/apiextensions-apiserver v0.33.2 // indirect + k8s.io/apimachinery v0.33.2 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect + sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) diff --git a/api/go.sum b/api/go.sum new file mode 100644 index 0000000..27a7206 --- /dev/null +++ b/api/go.sum @@ -0,0 +1,98 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fluxcd/pkg/apis/kustomize v1.10.0 h1:47EeSzkQvlQZdH92vHMe2lK2iR8aOSEJq95avw5idts= +github.com/fluxcd/pkg/apis/kustomize v1.10.0/go.mod h1:UsqMV4sqNa1Yg0pmTsdkHRJr7bafBOENIJoAN+3ezaQ= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/apiextensions-apiserver v0.33.2 h1:6gnkIbngnaUflR3XwE1mCefN3YS8yTD631JXQhsU6M8= +k8s.io/apiextensions-apiserver v0.33.2/go.mod h1:IvVanieYsEHJImTKXGP6XCOjTwv2LUMos0YWc9O+QP8= +k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY= +k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/api/jsonpatch/types.go b/api/jsonpatch/types.go new file mode 100644 index 0000000..6bb1d16 --- /dev/null +++ b/api/jsonpatch/types.go @@ -0,0 +1,30 @@ +// +kubebuilder:object:generate=true +package jsonpatch + +import ( + "github.com/fluxcd/pkg/apis/kustomize" +) + +// JSONPatch represents a JSON patch operation. +// Technically, a single JSON patch (as defined by RFC 6902) is a list of patch operations. +// Opposed to that, this type represents a single operation. Use the JSONPatches type for a list of operations instead. +type JSONPatch = kustomize.JSON6902 + +// JSONPatches is a list of JSON patch operations. +// This is technically a 'JSON patch' as defined in RFC 6902. +type JSONPatches []JSONPatch + +const ( + // ADD is the constant for the JSONPatch 'add' operation. + ADD = "add" + // REMOVE is the constant for the JSONPatch 'remove' operation. + REMOVE = "remove" + // REPLACE is the constant for the JSONPatch 'replace' operation. + REPLACE = "replace" + // MOVE is the constant for the JSONPatch 'move' operation. + MOVE = "move" + // COPY is the constant for the JSONPatch 'copy' operation. + COPY = "copy" + // TEST is the constant for the JSONPatch 'test' operation. + TEST = "test" +) diff --git a/api/jsonpatch/zz_generated.deepcopy.go b/api/jsonpatch/zz_generated.deepcopy.go new file mode 100644 index 0000000..d5140cc --- /dev/null +++ b/api/jsonpatch/zz_generated.deepcopy.go @@ -0,0 +1,26 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package jsonpatch + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in JSONPatches) DeepCopyInto(out *JSONPatches) { + { + in := &in + *out = make(JSONPatches, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JSONPatches. +func (in JSONPatches) DeepCopy() JSONPatches { + if in == nil { + return nil + } + out := new(JSONPatches) + in.DeepCopyInto(out) + return *out +} diff --git a/docs/README.md b/docs/README.md index aa50132..750b6d5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ - [Controller Utility Functions](libs/controller.md) - [Custom Resource Definitions](libs/crds.md) - [Error Handling](libs/errors.md) +- [JSON Patch](libs/jsonpatch.md) - [Logging](libs/logging.md) - [Key-Value Pairs](libs/pairs.md) - [Readiness Checks](libs/readiness.md) diff --git a/docs/libs/jsonpatch.md b/docs/libs/jsonpatch.md new file mode 100644 index 0000000..793a1d9 --- /dev/null +++ b/docs/libs/jsonpatch.md @@ -0,0 +1,115 @@ +# JSON Patch + +The `api/jsonpatch` package contains a `JSONPatches` type that represents a [JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902). +The type is ready to be used in a kubernetes resource type. + +The corresponding `pkg/jsonpatch` package contains helper functions to apply JSON patches specified via the aforementioned API type to a given JSON document or arbitrary go type. + +## Embedding the API Type + +```golang +import jpapi "github.com/openmcp-project/controller-utils/api/jsonpatch" + +type MyTypeSpec struct { + Patches jpapi.JSONPatches `json:"patches"` +} +``` + +## Patch Syntax + +The `pkg/jsonpatch` package handles JSON patches in form of the `JSONPatches` type from the `api/jsonpatch` package. The type can be safely embedded in k8s resources. +```yaml +patches: +- op: add + path: /foo/bar + value: foobar +- op: copy + path: /foo/baz + from: /foo/bar +``` + +`op` and `path` are required for each patch, `value` and `from` depend on the chosen operation. +Valid operations are `add`, `remove`, `replace`, `move`, `copy`, and `test`. + +### Path Notation + +The are two options for the notation of the `path` attribute: + +#### JSON Pointer Notation + +The first option is the JSON Pointer notation as described in [RFC 6901](https://datatracker.ietf.org/doc/html/rfc6901). Basically, each path segment is prefixed with `/`, with no differentiation between object fields and array indices. + +There are two special characters which need to be substituted: +- `~` has to be written as `~0` +- `/` has to be written as `~1` + +Examples: +- `/foo/bar` +- `/mylist/0/asdf` +- `/metadata/annotations/foo.bar.baz~1foobar` + +#### JSON Path Notation + +The second option is a simplified variant of the JSON Path notation as described in [RFC 9535](https://datatracker.ietf.org/doc/html/rfc9535). While the RFC specifies a full query language with function evaluation, the implementation here just allows referencing a single path. + +In short: +- path segments are separated by `.` or by using `[...]` + - the leading `.` is optional +- backslashes `\` are used to escape the special characters `\`, `.`, `[`, `]`, `'`, and `"` +- if the bracket notation is used to separate a path segment, single `'` or double `"` quotes may be used within the brackets + - the quote character has to immediately follow the opening bracket and immediately precede the closing bracket + - no escaping is required within brackets with quotes + +The table below shows a few examples of paths in the JSON Path notation and the corresponding JSON Pointer notation they are converted into. +| JSON Path Notation | JSON Pointer Notation | +| --- | --- | +| `.metadata.annotations.foo\.bar\.baz/foobar` | `/metadata/annotations/foo.bar.baz~1foobar` | +| `metadata.annotations.foo\.bar\.baz/foobar` | `/metadata/annotations/foo.bar.baz~1foobar` | +| `.metadata.annotations[foo\.bar\.baz/foobar]` | `/metadata/annotations/foo.bar.baz~1foobar` | +| `metadata.annotations["foo.bar.baz/foobar"]` | `/metadata/annotations/foo.bar.baz~1foobar` | +| `.metadata[annotations]['foo.bar.baz/foobar']` | `/metadata/annotations/foo.bar.baz~1foobar` | +| `.mylist[0].asdf` | `/mylist/0/asdf` | +| `mylist.0.asdf` | `/mylist/0/asdf` | + +## Applying the Patches + +### To a JSON Document + +```golang +import "github.com/openmcp-project/controller-utils/pkg/jsonpatch" + +// mytype.Spec is of type MyTypeSpec as defined in the above example +patch := jsonpatch.New(mytype.Spec.Patches) +// doc and modified are of type []byte +modified, err := patch.Apply(doc) +``` + +### To an Arbitrary Type + +The library supports applying JSON patches to arbitrary types. Internally, the object is marshalled to JSON, then the patch is applied, and then the object is unmarshalled into its original type again. The usual limitations of JSON (un)marshalling (no cyclic structures, etc.) apply. + +```golang +import "github.com/openmcp-project/controller-utils/pkg/jsonpatch" + +// mytype.Spec is of type MyTypeSpec as defined in the above example +patch := jsonpatch.NewTyped[MyPatchedType](mytype.Spec.Patches) +// obj and modified are of type MyPatchedType +modified, err := patch.Apply(doc) +``` + +### Options + +The `Apply` method optionally takes some options which can be constructed from functions contained in the package: +```golang +modified, err := patch.Apply(doc, jsonpatch.Indent(" ")) +``` + +The available options are: +- `SupportNegativeIndices` +- `AccumulatedCopySizeLimit` +- `AllowMissingPathOnRemove` +- `EnsurePathExistsOnAdd` +- `EscapeHTML` +- `Indent` + +The options are simply passed into the [library which is used internally](https://github.com/evanphx/json-patch). diff --git a/go.mod b/go.mod index edf9a3e..9097f21 100644 --- a/go.mod +++ b/go.mod @@ -2,12 +2,16 @@ module github.com/openmcp-project/controller-utils go 1.24.2 +replace github.com/openmcp-project/controller-utils/api => ./api + require ( + github.com/evanphx/json-patch/v5 v5.9.11 github.com/go-logr/logr v1.4.3 github.com/go-logr/zapr v1.3.0 github.com/onsi/ginkgo v1.16.5 github.com/onsi/ginkgo/v2 v2.23.4 github.com/onsi/gomega v1.37.0 + github.com/openmcp-project/controller-utils/api v0.0.0-00010101000000-000000000000 github.com/spf13/pflag v1.0.6 github.com/stretchr/testify v1.10.0 go.uber.org/zap v1.27.0 @@ -29,7 +33,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.12.1 // indirect github.com/evanphx/json-patch v4.12.0+incompatible // indirect - github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fluxcd/pkg/apis/kustomize v1.10.0 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect diff --git a/go.sum b/go.sum index f675e68..3027817 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fluxcd/pkg/apis/kustomize v1.10.0 h1:47EeSzkQvlQZdH92vHMe2lK2iR8aOSEJq95avw5idts= +github.com/fluxcd/pkg/apis/kustomize v1.10.0/go.mod h1:UsqMV4sqNa1Yg0pmTsdkHRJr7bafBOENIJoAN+3ezaQ= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= diff --git a/pkg/jsonpatch/patch.go b/pkg/jsonpatch/patch.go new file mode 100644 index 0000000..a15389c --- /dev/null +++ b/pkg/jsonpatch/patch.go @@ -0,0 +1,182 @@ +package jsonpatch + +import ( + "encoding/json" + "fmt" + "reflect" + + jplib "github.com/evanphx/json-patch/v5" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + + jpapi "github.com/openmcp-project/controller-utils/api/jsonpatch" +) + +type PatchValueData = apiextensionsv1.JSON + +type Untyped = []byte + +type Patch = TypedPatch[Untyped] + +type TypedPatch[T any] struct { + jpapi.JSONPatches +} + +type Options struct { + *jplib.ApplyOptions + + // Indent is the string used for indentation in the output JSON. + // Empty string means no indentation. + Indent string +} + +type Option func(*Options) + +// New creates a new JSONPatch with the given patches. +// This JSONPatch's Apply method works on plain JSON bytes. +// To apply the patches to an arbitrary type (which is marshalled to JSON before and unmarshalled back afterwards), +// use NewTyped instead. +func New(patches ...jpapi.JSONPatch) *Patch { + return &TypedPatch[Untyped]{ + JSONPatches: patches, + } +} + +// NewTyped creates a new TypedJSONPatch with the given patches. +func NewTyped[T any](patches ...jpapi.JSONPatch) *TypedPatch[T] { + return &TypedPatch[T]{ + JSONPatches: patches, + } +} + +// Apply applies the patch to the given document. +// If the generic type is Untyped (which is an alias for []byte), +// it will treat the document as raw JSON bytes. +// Otherwise, doc is marshalled to JSON before applying the patch and then again unmarshalled back to the original type afterwards. +func (p *TypedPatch[T]) Apply(doc T, options ...Option) (T, error) { + var result T + var rawDoc []byte + isUntyped := reflect.TypeFor[T]() == reflect.TypeFor[Untyped]() + if isUntyped { + rawDoc = any(doc).(Untyped) + } else { + tmp, err := json.Marshal(doc) + if err != nil { + return result, fmt.Errorf("failed to marshal document: %w", err) + } + rawDoc = tmp + } + + opts := &Options{ + ApplyOptions: jplib.NewApplyOptions(), + } + for _, opt := range options { + opt(opts) + } + + rawPatch, err := json.Marshal(p) + if err != nil { + return result, fmt.Errorf("failed to marshal JSONPatch: %w", err) + } + patch, err := jplib.DecodePatch(rawPatch) + if err != nil { + return result, fmt.Errorf("failed to decode JSONPatch: %w", err) + } + + if opts.Indent != "" { + rawDoc, err = patch.ApplyIndentWithOptions(rawDoc, opts.Indent, opts.ApplyOptions) + } else { + rawDoc, err = patch.ApplyWithOptions(rawDoc, opts.ApplyOptions) + } + if err != nil { + return result, fmt.Errorf("failed to apply JSONPatch: %w", err) + } + + if isUntyped { + return any(rawDoc).(T), nil + } + if err := json.Unmarshal(rawDoc, &result); err != nil { + return result, fmt.Errorf("failed to unmarshal result into type %T: %w", result, err) + } + return result, nil +} + +// SupportNegativeIndices decides whether to support non-standard practice of +// allowing negative indices to mean indices starting at the end of an array. +// Default to true. +func SupportNegativeIndices(val bool) Option { + return func(opts *Options) { + opts.SupportNegativeIndices = val + } +} + +// AccumulatedCopySizeLimit limits the total size increase in bytes caused by +// "copy" operations in a patch. +func AccumulatedCopySizeLimit(val int64) Option { + return func(opts *Options) { + opts.AccumulatedCopySizeLimit = val + } +} + +// AllowMissingPathOnRemove indicates whether to fail "remove" operations when the target path is missing. +// Default to false. +func AllowMissingPathOnRemove(val bool) Option { + return func(opts *Options) { + opts.AllowMissingPathOnRemove = val + } +} + +// EnsurePathExistsOnAdd instructs json-patch to recursively create the missing parts of path on "add" operation. +// Defaults to false. +func EnsurePathExistsOnAdd(val bool) Option { + return func(opts *Options) { + opts.EnsurePathExistsOnAdd = val + } +} + +// EscapeHTML sets the EscapeHTML flag for json marshalling. +// Defaults to true. +func EscapeHTML(val bool) Option { + return func(opts *Options) { + opts.EscapeHTML = val + } +} + +// Indent sets the indentation string for the output JSON. +// If empty, no indentation is applied. +func Indent(val string) Option { + return func(opts *Options) { + opts.Indent = val + } +} + +var _ json.Marshaler = &TypedPatch[Untyped]{} + +// MarshalJSON marshals the TypedJSONPatch to JSON. +// Note that this uses the ConvertPath function to ensure that the paths are in the correct format. +func (p *TypedPatch[T]) MarshalJSON() ([]byte, error) { + if p == nil { + return []byte("null"), nil + } + + // copy the single patches to convert their paths without modifying the original + patches := make(jpapi.JSONPatches, len(p.JSONPatches)) + for i, jsonPatch := range p.JSONPatches { + p := jsonPatch.DeepCopy() + convertedPath, iperr := ConvertPath(p.Path) + if iperr != nil { + return nil, fmt.Errorf("failed to convert path at index %d: %w", i, iperr) + } + p.Path = convertedPath + + convertedFrom, iperr := ConvertPath(p.From) + if iperr != nil { + return nil, fmt.Errorf("failed to convert 'from' path at index %d: %w", i, iperr) + } + p.From = convertedFrom + + patches[i] = *p + } + + return json.Marshal(patches) +} diff --git a/pkg/jsonpatch/patch_test.go b/pkg/jsonpatch/patch_test.go new file mode 100644 index 0000000..2e84426 --- /dev/null +++ b/pkg/jsonpatch/patch_test.go @@ -0,0 +1,246 @@ +package jsonpatch_test + +import ( + "encoding/json" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + jpapi "github.com/openmcp-project/controller-utils/api/jsonpatch" + "github.com/openmcp-project/controller-utils/pkg/jsonpatch" +) + +const ( + docBase = `{"foo":"bar","baz":{"foobar":"asdf"},"abc":[{"a":1},{"b":2},{"c":3}]}` +) + +var _ = Describe("JSONPatch", func() { + + var doc []byte + + BeforeEach(func() { + doc = []byte(docBase) + }) + + Context("Untyped", func() { + + It("should not do anything if the patch is empty", func() { + patch := jsonpatch.New(newPatches()...) + result, err := patch.Apply(doc) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(doc)) + Expect(doc).To(Equal([]byte(docBase))) + }) + + It("should apply a simple patch", func() { + patch := jsonpatch.New(newPatches(newPatch(jpapi.ADD, "/foo", "baz", ""))...) + result, err := patch.Apply(doc) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal([]byte(`{"foo":"baz","baz":{"foobar":"asdf"},"abc":[{"a":1},{"b":2},{"c":3}]}`))) + Expect(doc).To(Equal([]byte(docBase))) + }) + + It("should add an element to a list", func() { + patch := jsonpatch.New(newPatches(newPatch(jpapi.ADD, "/abc/-1", map[string]any{"d": 4}, ""))...) + result, err := patch.Apply(doc) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal([]byte(`{"foo":"bar","baz":{"foobar":"asdf"},"abc":[{"a":1},{"b":2},{"c":3},{"d":4}]}`))) + Expect(doc).To(Equal([]byte(docBase))) + }) + + It("should apply multiple patches in the correct order", func() { + patch := jsonpatch.New(newPatches( + newPatch(jpapi.ADD, "/foo", "baz", ""), + newPatch(jpapi.COPY, "/baz/foobar", nil, "/foo"), + newPatch(jpapi.REPLACE, "/abc/2/c", 6, ""), + newPatch(jpapi.REMOVE, "/abc/1", nil, ""), + )...) + result, err := patch.Apply(doc) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal([]byte(`{"foo":"baz","baz":{"foobar":"baz"},"abc":[{"a":1},{"c":6}]}`))) + Expect(doc).To(Equal([]byte(docBase))) + }) + + It("should handle paths that need conversion correctly", func() { + patch := jsonpatch.New(newPatches( + newPatch(jpapi.ADD, ".foo", "baz", ""), + newPatch(jpapi.COPY, "baz.foobar", nil, ".foo"), + newPatch(jpapi.REPLACE, "abc[2].c", 6, ""), + newPatch(jpapi.REMOVE, ".abc[1]", nil, ""), + )...) + result, err := patch.Apply(doc) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal([]byte(`{"foo":"baz","baz":{"foobar":"baz"},"abc":[{"a":1},{"c":6}]}`))) + Expect(doc).To(Equal([]byte(docBase))) + }) + + It("should apply options correctly", func() { + patch := jsonpatch.New(newPatches()...) + result, err := patch.Apply(doc, jsonpatch.Indent(" ")) + Expect(err).ToNot(HaveOccurred()) + Expect(string(result)).To(Equal(`{ + "foo": "bar", + "baz": { + "foobar": "asdf" + }, + "abc": [ + { + "a": 1 + }, + { + "b": 2 + }, + { + "c": 3 + } + ] +}`)) + Expect(doc).To(Equal([]byte(docBase))) + + patch = jsonpatch.New(newPatches( + newPatch(jpapi.REPLACE, "/abc/-1", map[string]any{"d": 4}, ""), + )...) + result, err = patch.Apply(doc) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal([]byte(`{"foo":"bar","baz":{"foobar":"asdf"},"abc":[{"a":1},{"b":2},{"d":4}]}`))) + Expect(doc).To(Equal([]byte(docBase))) + + _, err = patch.Apply(doc, jsonpatch.SupportNegativeIndices(false)) + Expect(err).To(HaveOccurred()) + }) + + }) + + Context("Typed", func() { + + type abc struct { + A int `json:"a,omitempty"` + B int `json:"b,omitempty"` + C int `json:"c,omitempty"` + } + + type baz struct { + Foobar string `json:"foobar"` + } + + type testDoc struct { + Foo string `json:"foo"` + Baz baz `json:"baz"` + ABC []abc `json:"abc"` + } + + var typedDoc *testDoc + var typedDocCompare *testDoc + + BeforeEach(func() { + typedDoc = &testDoc{} + err := json.Unmarshal([]byte(docBase), typedDoc) + Expect(err).ToNot(HaveOccurred()) + typedDocCompare = &testDoc{} + err = json.Unmarshal([]byte(docBase), typedDocCompare) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should not do anything if the patch is empty", func() { + patch := jsonpatch.NewTyped[*testDoc](newPatches()...) + result, err := patch.Apply(typedDoc) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(result).To(Equal(typedDoc)) + Expect(result == typedDoc).To(BeFalse(), "result should not be the same pointer as the input document") + Expect(typedDoc).To(Equal(typedDocCompare)) + }) + + It("should apply a simple patch", func() { + patch := jsonpatch.NewTyped[*testDoc](newPatches(newPatch(jpapi.ADD, "/foo", "baz", ""))...) + result, err := patch.Apply(typedDoc) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(result).To(Equal(&testDoc{ + Foo: "baz", + Baz: baz{Foobar: "asdf"}, + ABC: []abc{{A: 1}, {B: 2}, {C: 3}}, + })) + Expect(result == typedDoc).To(BeFalse(), "result should not be the same pointer as the input document") + Expect(typedDoc).To(Equal(typedDocCompare)) + }) + + It("should apply multiple patches in the correct order", func() { + patch := jsonpatch.NewTyped[*testDoc](newPatches( + newPatch(jpapi.ADD, "/foo", "baz", ""), + newPatch(jpapi.COPY, "/baz/foobar", nil, "/foo"), + newPatch(jpapi.REPLACE, "/abc/2/c", 6, ""), + newPatch(jpapi.REMOVE, "/abc/1", nil, ""), + )...) + result, err := patch.Apply(typedDoc) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(result).To(Equal(&testDoc{ + Foo: "baz", + Baz: baz{Foobar: "baz"}, + ABC: []abc{{A: 1}, {C: 6}}, + })) + Expect(result == typedDoc).To(BeFalse(), "result should not be the same pointer as the input document") + Expect(typedDoc).To(Equal(typedDocCompare)) + }) + + It("should handle paths that need conversion correctly", func() { + patch := jsonpatch.NewTyped[*testDoc](newPatches( + newPatch(jpapi.ADD, ".foo", "baz", ""), + newPatch(jpapi.COPY, "baz.foobar", nil, ".foo"), + newPatch(jpapi.REPLACE, "abc[2].c", 6, ""), + newPatch(jpapi.REMOVE, ".abc[1]", nil, ""), + )...) + result, err := patch.Apply(typedDoc) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(result).To(Equal(&testDoc{ + Foo: "baz", + Baz: baz{Foobar: "baz"}, + ABC: []abc{{A: 1}, {C: 6}}, + })) + Expect(result == typedDoc).To(BeFalse(), "result should not be the same pointer as the input document") + Expect(typedDoc).To(Equal(typedDocCompare)) + }) + + }) + + Context("API", func() { + + It("should be able to marshal and unmarshal JSONPatches", func() { + rawAPIPatches := []byte(`[{"op":"add","path":"/foo","from":"/bar","value":{"foobar":"foobaz"}}]`) + var apiPatches jpapi.JSONPatches + err := json.Unmarshal(rawAPIPatches, &apiPatches) + Expect(err).ToNot(HaveOccurred()) + Expect(apiPatches).To(ConsistOf(newPatch(jpapi.ADD, "/foo", map[string]any{"foobar": "foobaz"}, "/bar"))) + marshalled, err := json.Marshal(apiPatches) + Expect(err).ToNot(HaveOccurred()) + Expect(marshalled).To(Equal(rawAPIPatches)) + }) + + }) + +}) + +func newPatch(op, path string, value any, from string) jpapi.JSONPatch { + var valueData *jsonpatch.PatchValueData + if value != nil { + valueJSON, err := json.Marshal(value) + if err != nil { + panic(err) + } + valueData = &jsonpatch.PatchValueData{ + Raw: valueJSON, + } + } + return jpapi.JSONPatch{ + Op: op, + Path: path, + Value: valueData, + From: from, + } +} + +func newPatches(patches ...jpapi.JSONPatch) jpapi.JSONPatches { + return patches +} diff --git a/pkg/jsonpatch/path.go b/pkg/jsonpatch/path.go new file mode 100644 index 0000000..e41119e --- /dev/null +++ b/pkg/jsonpatch/path.go @@ -0,0 +1,183 @@ +package jsonpatch + +import ( + "fmt" + "strings" +) + +// ConvertPath takes a JSONPath-like path expression (.foo.bar[0].baz, .foo["bar"][0][baz]) and converts it into the format specified by the JSONPatch RFC (/foo/bar/0/baz). +// Rules: +// - The path expression may start with a dot (.). +// - Dots (.), square brackets ([, ]), and single (') or double (") quotes in field names are escaped with a preceding backslash (\). +// - Backslashes (\) in field names are escaped with a preceding backslash (\). +// - Field names are separated by either dots (.) or by wrapping them in square brackets ([]). +// - Dots (.) that appear within square brackets are treated as part of the field name, not as separators (even if not escaped). +// - Values in square brackets may be wrapped in double (") or single (') quotes, or may be unquoted. +// - Nesting brackets in brackets is not supported, unless the whole value in the outer brackets is in quotes, then the inner brackets are treated as part of the value. +// - The JSONPatch path expression does not differentiate between field names and array indices, so neither does this format. +// +// Noop if the path starts with a slash (/), because then it is expected to be in the JSONPatch format already. +// Returns just a slash (/) if the path is empty. +// Returns an error in case of an invalid path expression (non-matching brackets or quotes, wrong escaping, etc.). +// +// Note that the JSONPatch's Apply method calls this function automatically, it is usually not necessary to call this function directly. +func ConvertPath(path string) (string, *InvalidPathError) { + if path == "" { + return "/", nil + } + if strings.HasPrefix(path, "/") { + return path, nil + } + + // escape JSONPath special characters + path = strings.ReplaceAll(path, "~", "~0") // escape tilde (~) to ~0 + path = strings.ReplaceAll(path, "/", "~1") // escape slash (/) to ~1 + + segments := []string{} + index := 0 + for index < len(path) { + segment, newIndex, err := parseSegment(path, index) + if err != nil { + return "", err + } + segments = append(segments, segment) + index = newIndex + } + + return "/" + strings.Join(segments, "/"), nil +} + +// parseSegment parses a segment of the path expression. +// A segment may start with a dot (.) or an opening bracket ([). +// It ends when +// - a unescaped/unquoted dot (.) is found +// - an opening bracket ([) is found, if the segment did not start with one +// - there are no more characters in the input string +// Returns the extracted segment, the new index (pointing to the next character after the segment), and an error if something went wrong. +func parseSegment(data string, index int) (string, int, *InvalidPathError) { + if index >= len(data) { + return "", index, NewInvalidPathError(data, index, "", "unexpected end of input") + } + switch data[index] { + case '[': + return parseBracketed(data, index) + case '.': + // ignore leading dot + index++ + if index >= len(data) { + return "", index, NewInvalidPathError(data, index, "", "unexpected end of input after dot") + } + } + res := strings.Builder{} + for ; index < len(data); index++ { + c := string(data[index]) + switch c { + case ".", "[": + return res.String(), index, nil + case "'", "\"", "]": + return "", index, NewInvalidPathError(data, index, c, "invalid character") + case "\\": + val, newIndex, err := parseEscaped(data, index) + if err != nil { + return "", index, err + } + res.WriteString(val) + index = newIndex - 1 // -1 because the for loop will increment index + default: + res.WriteString(c) + } + } + return res.String(), index, nil +} + +// parseBracketed parses a bracketed segment of the path expression. +// It expects an opening bracket ([) at the current index, which may be followed by a single (') or double (") quote, or neither. +// It ends when it finds a closing bracket (]). If the opening bracket was followed by a quote, the closing bracket needs to be preceded by the same quote. +// Returns the extracted segment, the new index (pointing to the next character after the closing bracket), and an error if something went wrong. +func parseBracketed(data string, index int) (string, int, *InvalidPathError) { + if data[index] != '[' { + return "", index, NewInvalidPathError(data, index, string(data[0]), "expected opening bracket") + } + res := strings.Builder{} + index++ + if index >= len(data) { + return "", index, NewInvalidPathError(data, index, "[", "unexpected end of input after opening bracket") + } + delimiter := "]" + if data[index] == '"' || data[index] == '\'' { + delimiter = string(data[index]) + "]" + index++ + } + for ; index < len(data); index++ { + c := string(data[index]) + if c == string(delimiter[0]) { + // check if we reached the end of the bracketed value + if len(delimiter) == 1 { + return res.String(), index + 1, nil + } else if index+1 < len(data) && data[index+1] == delimiter[1] { + return res.String(), index + 2, nil + } + } + if len(delimiter) == 2 { + // we are in quotes, just take the character as is + res.WriteString(c) + continue + } + switch c { + case "\\": + val, newIndex, err := parseEscaped(data, index) + if err != nil { + return "", newIndex, err + } + res.WriteString(val) + index = newIndex + case "[", "]": + // not quoted, nesting brackets is not allowed + return "", index, NewInvalidPathError(data, index, c, "unescaped/unquoted opening or closing bracket inside brackets, nesting brackets is not supported") + default: + res.WriteString(c) + } + } + return "", index, NewInvalidPathError(data, index, "", "unexpected end of input, expected %s", delimiter) +} + +// parseEscaped parses an escape sequence in the path expression. +// It expects a backslash (\) at the current index, followed by a character that is either a backslash (\), a dot (.), an opening bracket ([), a closing bracket (]), +// a single quote ('), or a double quote ("). +// If the character is one of these, it returns the character as a string and the new index (pointing to the next character after the escape sequence). +// Otherwise, an error is returned. +func parseEscaped(data string, index int) (string, int, *InvalidPathError) { + if data[index] != '\\' { + return "", index, NewInvalidPathError(data, index, string(data[index]), "expected beginning of escape sequence") + } + index++ + if index >= len(data) { + return "", index + 1, NewInvalidPathError(data, index, "\\", "unexpected end of input after escape character") + } + c := string(data[index]) + if c == "\\" || c == "." || c == "[" || c == "]" || c == "'" || c == "\"" { + // valid escape sequence + return c, index + 1, nil + } + return "", index + 1, NewInvalidPathError(data, index, c, "invalid escape sequence, only \\.[]\"' are allowed to be escaped") +} + +type InvalidPathError struct { + Path string + Index int + Char string + Reason string +} + +func (e *InvalidPathError) Error() string { + return fmt.Sprintf("error parsing character '%s' at index %d in path '%s': %s", e.Char, e.Index, e.Path, e.Reason) +} + +func NewInvalidPathError(path string, index int, char string, reason string, args ...any) *InvalidPathError { + return &InvalidPathError{ + Path: path, + Index: index, + Char: char, + Reason: fmt.Sprintf(reason, args...), + } +} diff --git a/pkg/jsonpatch/path_test.go b/pkg/jsonpatch/path_test.go new file mode 100644 index 0000000..b5f32da --- /dev/null +++ b/pkg/jsonpatch/path_test.go @@ -0,0 +1,76 @@ +package jsonpatch_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/openmcp-project/controller-utils/pkg/jsonpatch" +) + +var _ = Describe("ConvertPath", func() { + + It("should convert simple paths correctly", func() { + verifyPathConversion("", "/") + verifyPathConversion("/", "/") + verifyPathConversion("a", "/a") + verifyPathConversion(".a", "/a") + verifyPathConversion("a.b", "/a/b") + verifyPathConversion(".a.b", "/a/b") + verifyPathConversion("a[0]", "/a/0") + verifyPathConversion("a[0].b", "/a/0/b") + verifyPathConversion("a[b][c]", "/a/b/c") + verifyPathConversion("a.b[c]", "/a/b/c") + verifyPathConversion("a[b.c].d", "/a/b.c/d") + }) + + It("should convert paths with quotes or escapes correctly", func() { + verifyPathConversion("a['b']", "/a/b") + verifyPathConversion("a[\"b\"]", "/a/b") + verifyPathConversion("a['b.c']", "/a/b.c") + verifyPathConversion("a[\"b.c\"]", "/a/b.c") + verifyPathConversion("a['b c']", "/a/b c") + verifyPathConversion("a[\"b c\"]", "/a/b c") + verifyPathConversion("a['b\\c']", "/a/b\\c") + verifyPathConversion("a[\"b\\c\"]", "/a/b\\c") + verifyPathConversion("a\\.b", "/a.b") + verifyPathConversion("a\\[b\\]", "/a[b]") + verifyPathConversion("a.\\'b\\'.c", "/a/'b'/c") + }) + + It("should handle paths with ~ and / characters correctly", func() { + verifyPathConversion(".a~b.c/d", "/a~0b/c~1d") + }) + + It("should throw an error for invalid paths", func() { + _, err := jsonpatch.ConvertPath("a[") + Expect(err).To(MatchError(ContainSubstring("unexpected end of input after opening bracket"))) + + _, err = jsonpatch.ConvertPath("a[foo") + Expect(err).To(MatchError(ContainSubstring("unexpected end of input"))) + + _, err = jsonpatch.ConvertPath("a['foo]") + Expect(err).To(MatchError(ContainSubstring("unexpected end of input"))) + + _, err = jsonpatch.ConvertPath("a[\"foo]") + Expect(err).To(MatchError(ContainSubstring("unexpected end of input"))) + + _, err = jsonpatch.ConvertPath("a]") + Expect(err).To(MatchError(ContainSubstring("invalid character"))) + + _, err = jsonpatch.ConvertPath("a\"") + Expect(err).To(MatchError(ContainSubstring("invalid character"))) + + _, err = jsonpatch.ConvertPath("a'") + Expect(err).To(MatchError(ContainSubstring("invalid character"))) + + _, err = jsonpatch.ConvertPath("a\\") + Expect(err).To(MatchError(ContainSubstring("unexpected end of input"))) + }) + +}) + +func verifyPathConversion(input, expected string) { + converted, err := jsonpatch.ConvertPath(input) + ExpectWithOffset(1, err).ToNot(HaveOccurred()) + ExpectWithOffset(1, converted).To(Equal(expected)) +} diff --git a/pkg/jsonpatch/suite_test.go b/pkg/jsonpatch/suite_test.go new file mode 100644 index 0000000..d52bb9a --- /dev/null +++ b/pkg/jsonpatch/suite_test.go @@ -0,0 +1,14 @@ +package jsonpatch_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCollections(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "JSONPatch Test Suite") +}