From 0959a5a5a1c8653d883a69777db84da9f756a789 Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Tue, 1 Jul 2025 15:59:24 +0200 Subject: [PATCH 1/7] add jsonpatch library and api module --- Taskfile.yaml | 3 +- api/go.mod | 3 + api/jsonpatch/types.go | 176 +++++++++++++++++++++++++++++ docs/README.md | 1 + docs/libs/jsonpatch.md | 59 ++++++++++ go.mod | 5 +- pkg/jsonpatch/patch.go | 180 ++++++++++++++++++++++++++++++ pkg/jsonpatch/patch_test.go | 215 ++++++++++++++++++++++++++++++++++++ pkg/jsonpatch/path.go | 179 ++++++++++++++++++++++++++++++ pkg/jsonpatch/path_test.go | 72 ++++++++++++ pkg/jsonpatch/suite_test.go | 14 +++ 11 files changed, 905 insertions(+), 2 deletions(-) create mode 100644 api/go.mod create mode 100644 api/jsonpatch/types.go create mode 100644 docs/libs/jsonpatch.md create mode 100644 pkg/jsonpatch/patch.go create mode 100644 pkg/jsonpatch/patch_test.go create mode 100644 pkg/jsonpatch/path.go create mode 100644 pkg/jsonpatch/path_test.go create mode 100644 pkg/jsonpatch/suite_test.go diff --git a/Taskfile.yaml b/Taskfile.yaml index b9ed62c..70d7817 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -5,5 +5,6 @@ includes: taskfile: hack/common/Taskfile_library.yaml flatten: true vars: - CODE_DIRS: '{{.ROOT_DIR}}/pkg/...' + NESTED_MODULES: 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..ea82cfb --- /dev/null +++ b/api/go.mod @@ -0,0 +1,3 @@ +module github.com/openmcp-project/controller-utils/api + +go 1.24.2 diff --git a/api/jsonpatch/types.go b/api/jsonpatch/types.go new file mode 100644 index 0000000..3b5fb99 --- /dev/null +++ b/api/jsonpatch/types.go @@ -0,0 +1,176 @@ +package jsonpatch + +import "encoding/json" + +// JSONPatch represents a JSON patch operation. +// Technically, a single JSON patch is already a list of patch operations. This type represents a single operation, use JSONPatches for a list of operations instead. +type JSONPatch struct { + // Operation is the operation to perform. + // +kubebuilder:validation:Enum=add;remove;replace;move;copy;test + // +kubebuilder:validation:Required + Operation Operation `json:"op"` + + // Path is the path to the target location in the JSON document. + // +kubebuilder:validation:Required + Path string `json:"path"` + + // Value is the value to set at the target location. + // Required for add, replace, and test operations. + // +optional + Value *Any `json:"value,omitempty"` + + // From is the source location for move and copy operations. + // +optional + From *string `json:"from,omitempty"` +} + +// JSONPatches is a list of JSON patch operations. +// This is technically a 'JSON patch' as defined in RFC 6902. +type JSONPatches []JSONPatch + +type Operation string + +const ( + // ADD is the constant for the JSONPatch 'add' operation. + ADD Operation = "add" + // REMOVE is the constant for the JSONPatch 'remove' operation. + REMOVE Operation = "remove" + // REPLACE is the constant for the JSONPatch 'replace' operation. + REPLACE Operation = "replace" + // MOVE is the constant for the JSONPatch 'move' operation. + MOVE Operation = "move" + // COPY is the constant for the JSONPatch 'copy' operation. + COPY Operation = "copy" + // TEST is the constant for the JSONPatch 'test' operation. + TEST Operation = "test" +) + +// NewJSONPatch creates a new JSONPatch with the given values. +func NewJSONPatch(op Operation, path string, value *Any, from *string) JSONPatch { + return JSONPatch{ + Operation: op, + Path: path, + Value: value, + From: from, + } +} + +// NewJSONPatches combines multiple JSONPatch instances into a single JSONPatches instance. +// This is a convenience function to create a JSONPatches instance from multiple JSONPatch instances. +func NewJSONPatches(patches ...JSONPatch) JSONPatches { + result := make(JSONPatches, 0, len(patches)) + for _, patch := range patches { + result = append(result, patch) + } + return result +} + +func NewAny(value any) *Any { + return &Any{Value: value} +} + +type Any struct { + Value any `json:"-"` +} + +var _ json.Marshaler = &Any{} +var _ json.Unmarshaler = &Any{} + +func (a *Any) MarshalJSON() ([]byte, error) { + if a == nil { + return []byte("null"), nil + } + return json.Marshal(a.Value) +} + +func (a *Any) UnmarshalJSON(data []byte) error { + if data == nil || string(data) == "null" { + a.Value = nil + return nil + } + return json.Unmarshal(data, &a.Value) +} + +func (in *JSONPatch) DeepCopy() *JSONPatch { + if in == nil { + return nil + } + out := &JSONPatch{} + in.DeepCopyInto(out) + return out +} + +func (in *JSONPatch) DeepCopyInto(out *JSONPatch) { + if out == nil { + return + } + out.Operation = in.Operation + out.Path = in.Path + if in.Value != nil { + valueCopy := *in.Value + out.Value = &valueCopy + } else { + out.Value = nil + } + if in.From != nil { + fromCopy := *in.From + out.From = &fromCopy + } else { + out.From = nil + } +} + +func (in *JSONPatches) DeepCopy() *JSONPatches { + if in == nil { + return nil + } + out := &JSONPatches{} + for _, item := range *in { + outItem := item.DeepCopy() + *out = append(*out, *outItem) + } + return out +} + +func (in *JSONPatches) DeepCopyInto(out *JSONPatches) { + if out == nil { + return + } + *out = make(JSONPatches, len(*in)) + for i, item := range *in { + outItem := item.DeepCopy() + (*out)[i] = *outItem + } +} + +func (in *Any) DeepCopy() *Any { + if in == nil { + return nil + } + out := &Any{} + in.DeepCopyInto(out) + return out +} + +func (in *Any) DeepCopyInto(out *Any) { + if out == nil { + return + } + if in.Value == nil { + out.Value = nil + return + } + // Use json.Marshal and json.Unmarshal to deep copy the value. + data, err := json.Marshal(in.Value) + if err != nil { + panic("failed to marshal Any value: " + err.Error()) + } + if err := json.Unmarshal(data, &out.Value); err != nil { + panic("failed to unmarshal Any value: " + err.Error()) + } +} + +// Ptr is a convenience function to create a pointer to the given value. +func Ptr[T any](val T) *T { + return &val +} 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..a458b41 --- /dev/null +++ b/docs/libs/jsonpatch.md @@ -0,0 +1,59 @@ +# 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"` +} +``` + +## 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..53522db 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,6 @@ 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/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/pkg/jsonpatch/patch.go b/pkg/jsonpatch/patch.go new file mode 100644 index 0000000..82fe802 --- /dev/null +++ b/pkg/jsonpatch/patch.go @@ -0,0 +1,180 @@ +package jsonpatch + +import ( + "encoding/json" + "fmt" + "reflect" + + jplib "github.com/evanphx/json-patch/v5" + + jpapi "github.com/openmcp-project/controller-utils/api/jsonpatch" +) + +type Untyped = []byte + +type JSONPatch = TypedJSONPatch[Untyped] + +type TypedJSONPatch[T any] struct { + jpapi.JSONPatches +} + +type JSONPatchOptions struct { + *jplib.ApplyOptions + + // Indent is the string used for indentation in the output JSON. + // Empty string means no indentation. + Indent string +} + +type JSONPatchOption func(*JSONPatchOptions) + +// 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.JSONPatches) *JSONPatch { + return &TypedJSONPatch[Untyped]{ + JSONPatches: patches, + } +} + +// NewTyped creates a new TypedJSONPatch with the given patches. +func NewTyped[T any](patches jpapi.JSONPatches) *TypedJSONPatch[T] { + return &TypedJSONPatch[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 *TypedJSONPatch[T]) Apply(doc T, options ...JSONPatchOption) (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 := &JSONPatchOptions{ + 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) JSONPatchOption { + return func(opts *JSONPatchOptions) { + opts.SupportNegativeIndices = val + } +} + +// AccumulatedCopySizeLimit limits the total size increase in bytes caused by +// "copy" operations in a patch. +func AccumulatedCopySizeLimit(val int64) JSONPatchOption { + return func(opts *JSONPatchOptions) { + opts.AccumulatedCopySizeLimit = val + } +} + +// AllowMissingPathOnRemove indicates whether to fail "remove" operations when the target path is missing. +// Default to false. +func AllowMissingPathOnRemove(val bool) JSONPatchOption { + return func(opts *JSONPatchOptions) { + 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) JSONPatchOption { + return func(opts *JSONPatchOptions) { + opts.EnsurePathExistsOnAdd = val + } +} + +// EscapeHTML sets the EscapeHTML flag for json marshalling. +// Defaults to true. +func EscapeHTML(val bool) JSONPatchOption { + return func(opts *JSONPatchOptions) { + opts.EscapeHTML = val + } +} + +// Indent sets the indentation string for the output JSON. +// If empty, no indentation is applied. +func Indent(val string) JSONPatchOption { + return func(opts *JSONPatchOptions) { + opts.Indent = val + } +} + +var _ json.Marshaler = &TypedJSONPatch[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 *TypedJSONPatch[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 + + if p.From != nil { + 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..8882a82 --- /dev/null +++ b/pkg/jsonpatch/patch_test.go @@ -0,0 +1,215 @@ +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(jpapi.NewJSONPatches()) + 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(jpapi.NewJSONPatches(jpapi.NewJSONPatch(jpapi.ADD, "/foo", jpapi.NewAny("baz"), nil))) + 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 apply multiple patches in the correct order", func() { + patch := jsonpatch.New(jpapi.NewJSONPatches( + jpapi.NewJSONPatch(jpapi.ADD, "/foo", jpapi.NewAny("baz"), nil), + jpapi.NewJSONPatch(jpapi.COPY, "/baz/foobar", nil, jpapi.Ptr("/foo")), + jpapi.NewJSONPatch(jpapi.REPLACE, "/abc/2/c", jpapi.NewAny(6), nil), + jpapi.NewJSONPatch(jpapi.REMOVE, "/abc/1", nil, 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(jpapi.NewJSONPatches( + jpapi.NewJSONPatch(jpapi.ADD, ".foo", jpapi.NewAny("baz"), nil), + jpapi.NewJSONPatch(jpapi.COPY, "baz.foobar", nil, jpapi.Ptr(".foo")), + jpapi.NewJSONPatch(jpapi.REPLACE, "abc[2].c", jpapi.NewAny(6), nil), + jpapi.NewJSONPatch(jpapi.REMOVE, ".abc[1]", nil, 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(jpapi.NewJSONPatches()) + 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(jpapi.NewJSONPatches( + jpapi.NewJSONPatch(jpapi.REPLACE, "/abc/-1", jpapi.NewAny(map[string]any{"d": 4}), nil), + )) + 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](jpapi.NewJSONPatches()) + 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](jpapi.NewJSONPatches(jpapi.NewJSONPatch(jpapi.ADD, "/foo", jpapi.NewAny("baz"), nil))) + 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](jpapi.NewJSONPatches( + jpapi.NewJSONPatch(jpapi.ADD, "/foo", jpapi.NewAny("baz"), nil), + jpapi.NewJSONPatch(jpapi.COPY, "/baz/foobar", nil, jpapi.Ptr("/foo")), + jpapi.NewJSONPatch(jpapi.REPLACE, "/abc/2/c", jpapi.NewAny(6), nil), + jpapi.NewJSONPatch(jpapi.REMOVE, "/abc/1", nil, 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](jpapi.NewJSONPatches( + jpapi.NewJSONPatch(jpapi.ADD, ".foo", jpapi.NewAny("baz"), nil), + jpapi.NewJSONPatch(jpapi.COPY, "baz.foobar", nil, jpapi.Ptr(".foo")), + jpapi.NewJSONPatch(jpapi.REPLACE, "abc[2].c", jpapi.NewAny(6), nil), + jpapi.NewJSONPatch(jpapi.REMOVE, ".abc[1]", nil, 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","value":{"foobar":"foobaz"},"from":"/bar"}]`) + var apiPatches jpapi.JSONPatches + err := json.Unmarshal(rawAPIPatches, &apiPatches) + Expect(err).ToNot(HaveOccurred()) + Expect(apiPatches).To(ConsistOf(jpapi.NewJSONPatch(jpapi.ADD, "/foo", jpapi.NewAny(map[string]any{"foobar": "foobaz"}), jpapi.Ptr("/bar")))) + marshalled, err := json.Marshal(apiPatches) + Expect(err).ToNot(HaveOccurred()) + Expect(marshalled).To(Equal(rawAPIPatches)) + }) + + }) + +}) diff --git a/pkg/jsonpatch/path.go b/pkg/jsonpatch/path.go new file mode 100644 index 0000000..9e7759d --- /dev/null +++ b/pkg/jsonpatch/path.go @@ -0,0 +1,179 @@ +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 + } + + 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..fad7327 --- /dev/null +++ b/pkg/jsonpatch/path_test.go @@ -0,0 +1,72 @@ +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 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") +} From edf1c79a27b9ffe35d09852dd2b5346709c2c914 Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Thu, 3 Jul 2025 10:01:40 +0200 Subject: [PATCH 2/7] remove superfluous 'json' from struct names in jsonpatch package --- pkg/jsonpatch/patch.go | 48 +++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/pkg/jsonpatch/patch.go b/pkg/jsonpatch/patch.go index 82fe802..b1bab80 100644 --- a/pkg/jsonpatch/patch.go +++ b/pkg/jsonpatch/patch.go @@ -12,13 +12,13 @@ import ( type Untyped = []byte -type JSONPatch = TypedJSONPatch[Untyped] +type Patch = TypedPatch[Untyped] -type TypedJSONPatch[T any] struct { +type TypedPatch[T any] struct { jpapi.JSONPatches } -type JSONPatchOptions struct { +type Options struct { *jplib.ApplyOptions // Indent is the string used for indentation in the output JSON. @@ -26,21 +26,21 @@ type JSONPatchOptions struct { Indent string } -type JSONPatchOption func(*JSONPatchOptions) +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.JSONPatches) *JSONPatch { - return &TypedJSONPatch[Untyped]{ +func New(patches jpapi.JSONPatches) *Patch { + return &TypedPatch[Untyped]{ JSONPatches: patches, } } // NewTyped creates a new TypedJSONPatch with the given patches. -func NewTyped[T any](patches jpapi.JSONPatches) *TypedJSONPatch[T] { - return &TypedJSONPatch[T]{ +func NewTyped[T any](patches jpapi.JSONPatches) *TypedPatch[T] { + return &TypedPatch[T]{ JSONPatches: patches, } } @@ -49,7 +49,7 @@ func NewTyped[T any](patches jpapi.JSONPatches) *TypedJSONPatch[T] { // 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 *TypedJSONPatch[T]) Apply(doc T, options ...JSONPatchOption) (T, error) { +func (p *TypedPatch[T]) Apply(doc T, options ...Option) (T, error) { var result T var rawDoc []byte isUntyped := reflect.TypeFor[T]() == reflect.TypeFor[Untyped]() @@ -63,7 +63,7 @@ func (p *TypedJSONPatch[T]) Apply(doc T, options ...JSONPatchOption) (T, error) rawDoc = tmp } - opts := &JSONPatchOptions{ + opts := &Options{ ApplyOptions: jplib.NewApplyOptions(), } for _, opt := range options { @@ -100,57 +100,57 @@ func (p *TypedJSONPatch[T]) Apply(doc T, options ...JSONPatchOption) (T, error) // 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) JSONPatchOption { - return func(opts *JSONPatchOptions) { +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) JSONPatchOption { - return func(opts *JSONPatchOptions) { +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) JSONPatchOption { - return func(opts *JSONPatchOptions) { +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) JSONPatchOption { - return func(opts *JSONPatchOptions) { +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) JSONPatchOption { - return func(opts *JSONPatchOptions) { +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) JSONPatchOption { - return func(opts *JSONPatchOptions) { +func Indent(val string) Option { + return func(opts *Options) { opts.Indent = val } } -var _ json.Marshaler = &TypedJSONPatch[Untyped]{} +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 *TypedJSONPatch[T]) MarshalJSON() ([]byte, error) { +func (p *TypedPatch[T]) MarshalJSON() ([]byte, error) { if p == nil { return []byte("null"), nil } From a0b7c39ad4d6547ebda82019da579f332bfc505a Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Thu, 3 Jul 2025 10:18:15 +0200 Subject: [PATCH 3/7] add test for adding element to a list --- pkg/jsonpatch/patch_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/jsonpatch/patch_test.go b/pkg/jsonpatch/patch_test.go index 8882a82..e61ead2 100644 --- a/pkg/jsonpatch/patch_test.go +++ b/pkg/jsonpatch/patch_test.go @@ -40,6 +40,14 @@ var _ = Describe("JSONPatch", func() { Expect(doc).To(Equal([]byte(docBase))) }) + It("should add an element to a list", func() { + patch := jsonpatch.New(jpapi.NewJSONPatches(jpapi.NewJSONPatch(jpapi.ADD, "/abc/-1", jpapi.NewAny(map[string]any{"d": 4}), nil))) + 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(jpapi.NewJSONPatches( jpapi.NewJSONPatch(jpapi.ADD, "/foo", jpapi.NewAny("baz"), nil), From a17b901a5c0acafb39b9500b976634f7c9247c69 Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Thu, 3 Jul 2025 15:53:16 +0200 Subject: [PATCH 4/7] fix kubebuilder tags for correct CRD generation --- api/jsonpatch/types.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/api/jsonpatch/types.go b/api/jsonpatch/types.go index 3b5fb99..2de93a2 100644 --- a/api/jsonpatch/types.go +++ b/api/jsonpatch/types.go @@ -3,9 +3,13 @@ package jsonpatch import "encoding/json" // JSONPatch represents a JSON patch operation. -// Technically, a single JSON patch is already a list of patch operations. This type represents a single operation, use JSONPatches for a list of operations instead. +// 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. +// +kubebuilder:validation:Schemaless +// +kubebuilder:validation:Type=object type JSONPatch struct { // Operation is the operation to perform. + // Valid values are: add, remove, replace, move, copy, test // +kubebuilder:validation:Enum=add;remove;replace;move;copy;test // +kubebuilder:validation:Required Operation Operation `json:"op"` @@ -16,6 +20,8 @@ type JSONPatch struct { // Value is the value to set at the target location. // Required for add, replace, and test operations. + // +kubebuilder:validation:Schemaless + // +kubebuilder:pruning:PreserveUnknownFields // +optional Value *Any `json:"value,omitempty"` From 94c9085cb902b50803a6823c9de987899dbf2362 Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Fri, 4 Jul 2025 09:55:24 +0200 Subject: [PATCH 5/7] fix jsonpointer special character handling --- docs/libs/jsonpatch.md | 56 ++++++++++++++++++++++++++++++++++++++ pkg/jsonpatch/path.go | 4 +++ pkg/jsonpatch/path_test.go | 4 +++ 3 files changed, 64 insertions(+) diff --git a/docs/libs/jsonpatch.md b/docs/libs/jsonpatch.md index a458b41..793a1d9 100644 --- a/docs/libs/jsonpatch.md +++ b/docs/libs/jsonpatch.md @@ -15,6 +15,62 @@ type MyTypeSpec struct { } ``` +## 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 diff --git a/pkg/jsonpatch/path.go b/pkg/jsonpatch/path.go index 9e7759d..e41119e 100644 --- a/pkg/jsonpatch/path.go +++ b/pkg/jsonpatch/path.go @@ -29,6 +29,10 @@ func ConvertPath(path string) (string, *InvalidPathError) { 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) { diff --git a/pkg/jsonpatch/path_test.go b/pkg/jsonpatch/path_test.go index fad7327..b5f32da 100644 --- a/pkg/jsonpatch/path_test.go +++ b/pkg/jsonpatch/path_test.go @@ -37,6 +37,10 @@ var _ = Describe("ConvertPath", func() { 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"))) From 40e2d532aca38ab68e9f4429302196d511e144b9 Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Fri, 4 Jul 2025 14:06:50 +0200 Subject: [PATCH 6/7] implement review feedback --- Taskfile.yaml | 1 + api/go.mod | 22 ++++ api/go.sum | 86 +++++++++++++++ api/jsonpatch/types.go | 143 +++++-------------------- api/jsonpatch/zz_generated.deepcopy.go | 55 ++++++++++ pkg/jsonpatch/patch_test.go | 43 ++++---- 6 files changed, 214 insertions(+), 136 deletions(-) create mode 100644 api/go.sum create mode 100644 api/jsonpatch/zz_generated.deepcopy.go diff --git a/Taskfile.yaml b/Taskfile.yaml index 70d7817..719ade5 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -6,5 +6,6 @@ includes: flatten: true vars: 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 index ea82cfb..9980b46 100644 --- a/api/go.mod +++ b/api/go.mod @@ -1,3 +1,25 @@ module github.com/openmcp-project/controller-utils/api go 1.24.2 + +require k8s.io/apiextensions-apiserver v0.33.2 + +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/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/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..e1f25a0 --- /dev/null +++ b/api/go.sum @@ -0,0 +1,86 @@ +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/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/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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 index 2de93a2..a243901 100644 --- a/api/jsonpatch/types.go +++ b/api/jsonpatch/types.go @@ -1,6 +1,11 @@ +// +kubebuilder:object:generate=true package jsonpatch -import "encoding/json" +import ( + "encoding/json" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) // JSONPatch represents a JSON patch operation. // Technically, a single JSON patch (as defined by RFC 6902) is a list of patch operations. @@ -23,7 +28,7 @@ type JSONPatch struct { // +kubebuilder:validation:Schemaless // +kubebuilder:pruning:PreserveUnknownFields // +optional - Value *Any `json:"value,omitempty"` + Value *apiextensionsv1.JSON `json:"value,omitempty"` // From is the source location for move and copy operations. // +optional @@ -52,13 +57,31 @@ const ( ) // NewJSONPatch creates a new JSONPatch with the given values. -func NewJSONPatch(op Operation, path string, value *Any, from *string) JSONPatch { - return JSONPatch{ +// If value is non-nil, it is marshaled to JSON. Returns an error if the value cannot be marshaled. +func NewJSONPatch(op Operation, path string, value any, from *string) (JSONPatch, error) { + res := JSONPatch{ Operation: op, Path: path, - Value: value, From: from, } + var err error + if value != nil { + var valueJSON []byte + valueJSON, err = json.Marshal(value) + res.Value = &apiextensionsv1.JSON{ + Raw: valueJSON, + } + } + return res, err +} + +// NewJSONPatchOrPanic works like NewJSONPatch, but instead of returning an error, it panics if the patch cannot be created. +func NewJSONPatchOrPanic(op Operation, path string, value any, from *string) JSONPatch { + patch, err := NewJSONPatch(op, path, value, from) + if err != nil { + panic(err) + } + return patch } // NewJSONPatches combines multiple JSONPatch instances into a single JSONPatches instance. @@ -70,113 +93,3 @@ func NewJSONPatches(patches ...JSONPatch) JSONPatches { } return result } - -func NewAny(value any) *Any { - return &Any{Value: value} -} - -type Any struct { - Value any `json:"-"` -} - -var _ json.Marshaler = &Any{} -var _ json.Unmarshaler = &Any{} - -func (a *Any) MarshalJSON() ([]byte, error) { - if a == nil { - return []byte("null"), nil - } - return json.Marshal(a.Value) -} - -func (a *Any) UnmarshalJSON(data []byte) error { - if data == nil || string(data) == "null" { - a.Value = nil - return nil - } - return json.Unmarshal(data, &a.Value) -} - -func (in *JSONPatch) DeepCopy() *JSONPatch { - if in == nil { - return nil - } - out := &JSONPatch{} - in.DeepCopyInto(out) - return out -} - -func (in *JSONPatch) DeepCopyInto(out *JSONPatch) { - if out == nil { - return - } - out.Operation = in.Operation - out.Path = in.Path - if in.Value != nil { - valueCopy := *in.Value - out.Value = &valueCopy - } else { - out.Value = nil - } - if in.From != nil { - fromCopy := *in.From - out.From = &fromCopy - } else { - out.From = nil - } -} - -func (in *JSONPatches) DeepCopy() *JSONPatches { - if in == nil { - return nil - } - out := &JSONPatches{} - for _, item := range *in { - outItem := item.DeepCopy() - *out = append(*out, *outItem) - } - return out -} - -func (in *JSONPatches) DeepCopyInto(out *JSONPatches) { - if out == nil { - return - } - *out = make(JSONPatches, len(*in)) - for i, item := range *in { - outItem := item.DeepCopy() - (*out)[i] = *outItem - } -} - -func (in *Any) DeepCopy() *Any { - if in == nil { - return nil - } - out := &Any{} - in.DeepCopyInto(out) - return out -} - -func (in *Any) DeepCopyInto(out *Any) { - if out == nil { - return - } - if in.Value == nil { - out.Value = nil - return - } - // Use json.Marshal and json.Unmarshal to deep copy the value. - data, err := json.Marshal(in.Value) - if err != nil { - panic("failed to marshal Any value: " + err.Error()) - } - if err := json.Unmarshal(data, &out.Value); err != nil { - panic("failed to unmarshal Any value: " + err.Error()) - } -} - -// Ptr is a convenience function to create a pointer to the given value. -func Ptr[T any](val T) *T { - return &val -} diff --git a/api/jsonpatch/zz_generated.deepcopy.go b/api/jsonpatch/zz_generated.deepcopy.go new file mode 100644 index 0000000..174c2f9 --- /dev/null +++ b/api/jsonpatch/zz_generated.deepcopy.go @@ -0,0 +1,55 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package jsonpatch + +import ( + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *JSONPatch) DeepCopyInto(out *JSONPatch) { + *out = *in + if in.Value != nil { + in, out := &in.Value, &out.Value + *out = new(v1.JSON) + (*in).DeepCopyInto(*out) + } + if in.From != nil { + in, out := &in.From, &out.From + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JSONPatch. +func (in *JSONPatch) DeepCopy() *JSONPatch { + if in == nil { + return nil + } + out := new(JSONPatch) + in.DeepCopyInto(out) + return out +} + +// 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/pkg/jsonpatch/patch_test.go b/pkg/jsonpatch/patch_test.go index e61ead2..ab75d2f 100644 --- a/pkg/jsonpatch/patch_test.go +++ b/pkg/jsonpatch/patch_test.go @@ -5,6 +5,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "k8s.io/utils/ptr" jpapi "github.com/openmcp-project/controller-utils/api/jsonpatch" "github.com/openmcp-project/controller-utils/pkg/jsonpatch" @@ -33,7 +34,7 @@ var _ = Describe("JSONPatch", func() { }) It("should apply a simple patch", func() { - patch := jsonpatch.New(jpapi.NewJSONPatches(jpapi.NewJSONPatch(jpapi.ADD, "/foo", jpapi.NewAny("baz"), nil))) + patch := jsonpatch.New(jpapi.NewJSONPatches(jpapi.NewJSONPatchOrPanic(jpapi.ADD, "/foo", "baz", nil))) 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}]}`))) @@ -41,7 +42,7 @@ var _ = Describe("JSONPatch", func() { }) It("should add an element to a list", func() { - patch := jsonpatch.New(jpapi.NewJSONPatches(jpapi.NewJSONPatch(jpapi.ADD, "/abc/-1", jpapi.NewAny(map[string]any{"d": 4}), nil))) + patch := jsonpatch.New(jpapi.NewJSONPatches(jpapi.NewJSONPatchOrPanic(jpapi.ADD, "/abc/-1", map[string]any{"d": 4}, nil))) 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}]}`))) @@ -50,10 +51,10 @@ var _ = Describe("JSONPatch", func() { It("should apply multiple patches in the correct order", func() { patch := jsonpatch.New(jpapi.NewJSONPatches( - jpapi.NewJSONPatch(jpapi.ADD, "/foo", jpapi.NewAny("baz"), nil), - jpapi.NewJSONPatch(jpapi.COPY, "/baz/foobar", nil, jpapi.Ptr("/foo")), - jpapi.NewJSONPatch(jpapi.REPLACE, "/abc/2/c", jpapi.NewAny(6), nil), - jpapi.NewJSONPatch(jpapi.REMOVE, "/abc/1", nil, nil), + jpapi.NewJSONPatchOrPanic(jpapi.ADD, "/foo", "baz", nil), + jpapi.NewJSONPatchOrPanic(jpapi.COPY, "/baz/foobar", nil, ptr.To("/foo")), + jpapi.NewJSONPatchOrPanic(jpapi.REPLACE, "/abc/2/c", 6, nil), + jpapi.NewJSONPatchOrPanic(jpapi.REMOVE, "/abc/1", nil, nil), )) result, err := patch.Apply(doc) Expect(err).ToNot(HaveOccurred()) @@ -63,10 +64,10 @@ var _ = Describe("JSONPatch", func() { It("should handle paths that need conversion correctly", func() { patch := jsonpatch.New(jpapi.NewJSONPatches( - jpapi.NewJSONPatch(jpapi.ADD, ".foo", jpapi.NewAny("baz"), nil), - jpapi.NewJSONPatch(jpapi.COPY, "baz.foobar", nil, jpapi.Ptr(".foo")), - jpapi.NewJSONPatch(jpapi.REPLACE, "abc[2].c", jpapi.NewAny(6), nil), - jpapi.NewJSONPatch(jpapi.REMOVE, ".abc[1]", nil, nil), + jpapi.NewJSONPatchOrPanic(jpapi.ADD, ".foo", "baz", nil), + jpapi.NewJSONPatchOrPanic(jpapi.COPY, "baz.foobar", nil, ptr.To(".foo")), + jpapi.NewJSONPatchOrPanic(jpapi.REPLACE, "abc[2].c", 6, nil), + jpapi.NewJSONPatchOrPanic(jpapi.REMOVE, ".abc[1]", nil, nil), )) result, err := patch.Apply(doc) Expect(err).ToNot(HaveOccurred()) @@ -98,7 +99,7 @@ var _ = Describe("JSONPatch", func() { Expect(doc).To(Equal([]byte(docBase))) patch = jsonpatch.New(jpapi.NewJSONPatches( - jpapi.NewJSONPatch(jpapi.REPLACE, "/abc/-1", jpapi.NewAny(map[string]any{"d": 4}), nil), + jpapi.NewJSONPatchOrPanic(jpapi.REPLACE, "/abc/-1", map[string]any{"d": 4}, nil), )) result, err = patch.Apply(doc) Expect(err).ToNot(HaveOccurred()) @@ -152,7 +153,7 @@ var _ = Describe("JSONPatch", func() { }) It("should apply a simple patch", func() { - patch := jsonpatch.NewTyped[*testDoc](jpapi.NewJSONPatches(jpapi.NewJSONPatch(jpapi.ADD, "/foo", jpapi.NewAny("baz"), nil))) + patch := jsonpatch.NewTyped[*testDoc](jpapi.NewJSONPatches(jpapi.NewJSONPatchOrPanic(jpapi.ADD, "/foo", "baz", nil))) result, err := patch.Apply(typedDoc) Expect(err).ToNot(HaveOccurred()) Expect(result).ToNot(BeNil()) @@ -167,10 +168,10 @@ var _ = Describe("JSONPatch", func() { It("should apply multiple patches in the correct order", func() { patch := jsonpatch.NewTyped[*testDoc](jpapi.NewJSONPatches( - jpapi.NewJSONPatch(jpapi.ADD, "/foo", jpapi.NewAny("baz"), nil), - jpapi.NewJSONPatch(jpapi.COPY, "/baz/foobar", nil, jpapi.Ptr("/foo")), - jpapi.NewJSONPatch(jpapi.REPLACE, "/abc/2/c", jpapi.NewAny(6), nil), - jpapi.NewJSONPatch(jpapi.REMOVE, "/abc/1", nil, nil), + jpapi.NewJSONPatchOrPanic(jpapi.ADD, "/foo", "baz", nil), + jpapi.NewJSONPatchOrPanic(jpapi.COPY, "/baz/foobar", nil, ptr.To("/foo")), + jpapi.NewJSONPatchOrPanic(jpapi.REPLACE, "/abc/2/c", 6, nil), + jpapi.NewJSONPatchOrPanic(jpapi.REMOVE, "/abc/1", nil, nil), )) result, err := patch.Apply(typedDoc) Expect(err).ToNot(HaveOccurred()) @@ -186,10 +187,10 @@ var _ = Describe("JSONPatch", func() { It("should handle paths that need conversion correctly", func() { patch := jsonpatch.NewTyped[*testDoc](jpapi.NewJSONPatches( - jpapi.NewJSONPatch(jpapi.ADD, ".foo", jpapi.NewAny("baz"), nil), - jpapi.NewJSONPatch(jpapi.COPY, "baz.foobar", nil, jpapi.Ptr(".foo")), - jpapi.NewJSONPatch(jpapi.REPLACE, "abc[2].c", jpapi.NewAny(6), nil), - jpapi.NewJSONPatch(jpapi.REMOVE, ".abc[1]", nil, nil), + jpapi.NewJSONPatchOrPanic(jpapi.ADD, ".foo", "baz", nil), + jpapi.NewJSONPatchOrPanic(jpapi.COPY, "baz.foobar", nil, ptr.To(".foo")), + jpapi.NewJSONPatchOrPanic(jpapi.REPLACE, "abc[2].c", 6, nil), + jpapi.NewJSONPatchOrPanic(jpapi.REMOVE, ".abc[1]", nil, nil), )) result, err := patch.Apply(typedDoc) Expect(err).ToNot(HaveOccurred()) @@ -212,7 +213,7 @@ var _ = Describe("JSONPatch", func() { var apiPatches jpapi.JSONPatches err := json.Unmarshal(rawAPIPatches, &apiPatches) Expect(err).ToNot(HaveOccurred()) - Expect(apiPatches).To(ConsistOf(jpapi.NewJSONPatch(jpapi.ADD, "/foo", jpapi.NewAny(map[string]any{"foobar": "foobaz"}), jpapi.Ptr("/bar")))) + Expect(apiPatches).To(ConsistOf(jpapi.NewJSONPatchOrPanic(jpapi.ADD, "/foo", map[string]any{"foobar": "foobaz"}, ptr.To("/bar")))) marshalled, err := json.Marshal(apiPatches) Expect(err).ToNot(HaveOccurred()) Expect(marshalled).To(Equal(rawAPIPatches)) From 52bbde7b4c86b868b721e03db4003083a7a365d4 Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Fri, 4 Jul 2025 15:37:54 +0200 Subject: [PATCH 7/7] use json patch api type from kustomize api --- api/go.mod | 4 +- api/go.sum | 14 +++- api/jsonpatch/types.go | 81 +++------------------- api/jsonpatch/zz_generated.deepcopy.go | 29 -------- go.mod | 1 + go.sum | 2 + pkg/jsonpatch/patch.go | 18 ++--- pkg/jsonpatch/patch_test.go | 94 ++++++++++++++++---------- 8 files changed, 95 insertions(+), 148 deletions(-) diff --git a/api/go.mod b/api/go.mod index 9980b46..09e34d8 100644 --- a/api/go.mod +++ b/api/go.mod @@ -2,19 +2,21 @@ module github.com/openmcp-project/controller-utils/api go 1.24.2 -require k8s.io/apiextensions-apiserver v0.33.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 diff --git a/api/go.sum b/api/go.sum index e1f25a0..27a7206 100644 --- a/api/go.sum +++ b/api/go.sum @@ -1,6 +1,9 @@ +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= @@ -15,13 +18,21 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr 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= @@ -61,8 +72,9 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 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= diff --git a/api/jsonpatch/types.go b/api/jsonpatch/types.go index a243901..6bb1d16 100644 --- a/api/jsonpatch/types.go +++ b/api/jsonpatch/types.go @@ -2,94 +2,29 @@ package jsonpatch import ( - "encoding/json" - - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "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. -// +kubebuilder:validation:Schemaless -// +kubebuilder:validation:Type=object -type JSONPatch struct { - // Operation is the operation to perform. - // Valid values are: add, remove, replace, move, copy, test - // +kubebuilder:validation:Enum=add;remove;replace;move;copy;test - // +kubebuilder:validation:Required - Operation Operation `json:"op"` - - // Path is the path to the target location in the JSON document. - // +kubebuilder:validation:Required - Path string `json:"path"` - - // Value is the value to set at the target location. - // Required for add, replace, and test operations. - // +kubebuilder:validation:Schemaless - // +kubebuilder:pruning:PreserveUnknownFields - // +optional - Value *apiextensionsv1.JSON `json:"value,omitempty"` - - // From is the source location for move and copy operations. - // +optional - From *string `json:"from,omitempty"` -} +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 -type Operation string - const ( // ADD is the constant for the JSONPatch 'add' operation. - ADD Operation = "add" + ADD = "add" // REMOVE is the constant for the JSONPatch 'remove' operation. - REMOVE Operation = "remove" + REMOVE = "remove" // REPLACE is the constant for the JSONPatch 'replace' operation. - REPLACE Operation = "replace" + REPLACE = "replace" // MOVE is the constant for the JSONPatch 'move' operation. - MOVE Operation = "move" + MOVE = "move" // COPY is the constant for the JSONPatch 'copy' operation. - COPY Operation = "copy" + COPY = "copy" // TEST is the constant for the JSONPatch 'test' operation. - TEST Operation = "test" + TEST = "test" ) - -// NewJSONPatch creates a new JSONPatch with the given values. -// If value is non-nil, it is marshaled to JSON. Returns an error if the value cannot be marshaled. -func NewJSONPatch(op Operation, path string, value any, from *string) (JSONPatch, error) { - res := JSONPatch{ - Operation: op, - Path: path, - From: from, - } - var err error - if value != nil { - var valueJSON []byte - valueJSON, err = json.Marshal(value) - res.Value = &apiextensionsv1.JSON{ - Raw: valueJSON, - } - } - return res, err -} - -// NewJSONPatchOrPanic works like NewJSONPatch, but instead of returning an error, it panics if the patch cannot be created. -func NewJSONPatchOrPanic(op Operation, path string, value any, from *string) JSONPatch { - patch, err := NewJSONPatch(op, path, value, from) - if err != nil { - panic(err) - } - return patch -} - -// NewJSONPatches combines multiple JSONPatch instances into a single JSONPatches instance. -// This is a convenience function to create a JSONPatches instance from multiple JSONPatch instances. -func NewJSONPatches(patches ...JSONPatch) JSONPatches { - result := make(JSONPatches, 0, len(patches)) - for _, patch := range patches { - result = append(result, patch) - } - return result -} diff --git a/api/jsonpatch/zz_generated.deepcopy.go b/api/jsonpatch/zz_generated.deepcopy.go index 174c2f9..d5140cc 100644 --- a/api/jsonpatch/zz_generated.deepcopy.go +++ b/api/jsonpatch/zz_generated.deepcopy.go @@ -4,35 +4,6 @@ package jsonpatch -import ( - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" -) - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *JSONPatch) DeepCopyInto(out *JSONPatch) { - *out = *in - if in.Value != nil { - in, out := &in.Value, &out.Value - *out = new(v1.JSON) - (*in).DeepCopyInto(*out) - } - if in.From != nil { - in, out := &in.From, &out.From - *out = new(string) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JSONPatch. -func (in *JSONPatch) DeepCopy() *JSONPatch { - if in == nil { - return nil - } - out := new(JSONPatch) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in JSONPatches) DeepCopyInto(out *JSONPatches) { { diff --git a/go.mod b/go.mod index 53522db..9097f21 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +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/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 index b1bab80..a15389c 100644 --- a/pkg/jsonpatch/patch.go +++ b/pkg/jsonpatch/patch.go @@ -7,9 +7,13 @@ import ( 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] @@ -32,14 +36,14 @@ type Option func(*Options) // 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.JSONPatches) *Patch { +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.JSONPatches) *TypedPatch[T] { +func NewTyped[T any](patches ...jpapi.JSONPatch) *TypedPatch[T] { return &TypedPatch[T]{ JSONPatches: patches, } @@ -165,13 +169,11 @@ func (p *TypedPatch[T]) MarshalJSON() ([]byte, error) { } p.Path = convertedPath - if p.From != nil { - 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 + 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 } diff --git a/pkg/jsonpatch/patch_test.go b/pkg/jsonpatch/patch_test.go index ab75d2f..2e84426 100644 --- a/pkg/jsonpatch/patch_test.go +++ b/pkg/jsonpatch/patch_test.go @@ -5,7 +5,6 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "k8s.io/utils/ptr" jpapi "github.com/openmcp-project/controller-utils/api/jsonpatch" "github.com/openmcp-project/controller-utils/pkg/jsonpatch" @@ -26,7 +25,7 @@ var _ = Describe("JSONPatch", func() { Context("Untyped", func() { It("should not do anything if the patch is empty", func() { - patch := jsonpatch.New(jpapi.NewJSONPatches()) + patch := jsonpatch.New(newPatches()...) result, err := patch.Apply(doc) Expect(err).ToNot(HaveOccurred()) Expect(result).To(Equal(doc)) @@ -34,7 +33,7 @@ var _ = Describe("JSONPatch", func() { }) It("should apply a simple patch", func() { - patch := jsonpatch.New(jpapi.NewJSONPatches(jpapi.NewJSONPatchOrPanic(jpapi.ADD, "/foo", "baz", nil))) + 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}]}`))) @@ -42,7 +41,7 @@ var _ = Describe("JSONPatch", func() { }) It("should add an element to a list", func() { - patch := jsonpatch.New(jpapi.NewJSONPatches(jpapi.NewJSONPatchOrPanic(jpapi.ADD, "/abc/-1", map[string]any{"d": 4}, nil))) + 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}]}`))) @@ -50,12 +49,12 @@ var _ = Describe("JSONPatch", func() { }) It("should apply multiple patches in the correct order", func() { - patch := jsonpatch.New(jpapi.NewJSONPatches( - jpapi.NewJSONPatchOrPanic(jpapi.ADD, "/foo", "baz", nil), - jpapi.NewJSONPatchOrPanic(jpapi.COPY, "/baz/foobar", nil, ptr.To("/foo")), - jpapi.NewJSONPatchOrPanic(jpapi.REPLACE, "/abc/2/c", 6, nil), - jpapi.NewJSONPatchOrPanic(jpapi.REMOVE, "/abc/1", nil, nil), - )) + 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}]}`))) @@ -63,12 +62,12 @@ var _ = Describe("JSONPatch", func() { }) It("should handle paths that need conversion correctly", func() { - patch := jsonpatch.New(jpapi.NewJSONPatches( - jpapi.NewJSONPatchOrPanic(jpapi.ADD, ".foo", "baz", nil), - jpapi.NewJSONPatchOrPanic(jpapi.COPY, "baz.foobar", nil, ptr.To(".foo")), - jpapi.NewJSONPatchOrPanic(jpapi.REPLACE, "abc[2].c", 6, nil), - jpapi.NewJSONPatchOrPanic(jpapi.REMOVE, ".abc[1]", nil, nil), - )) + 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}]}`))) @@ -76,7 +75,7 @@ var _ = Describe("JSONPatch", func() { }) It("should apply options correctly", func() { - patch := jsonpatch.New(jpapi.NewJSONPatches()) + patch := jsonpatch.New(newPatches()...) result, err := patch.Apply(doc, jsonpatch.Indent(" ")) Expect(err).ToNot(HaveOccurred()) Expect(string(result)).To(Equal(`{ @@ -98,9 +97,9 @@ var _ = Describe("JSONPatch", func() { }`)) Expect(doc).To(Equal([]byte(docBase))) - patch = jsonpatch.New(jpapi.NewJSONPatches( - jpapi.NewJSONPatchOrPanic(jpapi.REPLACE, "/abc/-1", map[string]any{"d": 4}, nil), - )) + 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}]}`))) @@ -143,7 +142,7 @@ var _ = Describe("JSONPatch", func() { }) It("should not do anything if the patch is empty", func() { - patch := jsonpatch.NewTyped[*testDoc](jpapi.NewJSONPatches()) + patch := jsonpatch.NewTyped[*testDoc](newPatches()...) result, err := patch.Apply(typedDoc) Expect(err).ToNot(HaveOccurred()) Expect(result).ToNot(BeNil()) @@ -153,7 +152,7 @@ var _ = Describe("JSONPatch", func() { }) It("should apply a simple patch", func() { - patch := jsonpatch.NewTyped[*testDoc](jpapi.NewJSONPatches(jpapi.NewJSONPatchOrPanic(jpapi.ADD, "/foo", "baz", nil))) + patch := jsonpatch.NewTyped[*testDoc](newPatches(newPatch(jpapi.ADD, "/foo", "baz", ""))...) result, err := patch.Apply(typedDoc) Expect(err).ToNot(HaveOccurred()) Expect(result).ToNot(BeNil()) @@ -167,12 +166,12 @@ var _ = Describe("JSONPatch", func() { }) It("should apply multiple patches in the correct order", func() { - patch := jsonpatch.NewTyped[*testDoc](jpapi.NewJSONPatches( - jpapi.NewJSONPatchOrPanic(jpapi.ADD, "/foo", "baz", nil), - jpapi.NewJSONPatchOrPanic(jpapi.COPY, "/baz/foobar", nil, ptr.To("/foo")), - jpapi.NewJSONPatchOrPanic(jpapi.REPLACE, "/abc/2/c", 6, nil), - jpapi.NewJSONPatchOrPanic(jpapi.REMOVE, "/abc/1", nil, nil), - )) + 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()) @@ -186,12 +185,12 @@ var _ = Describe("JSONPatch", func() { }) It("should handle paths that need conversion correctly", func() { - patch := jsonpatch.NewTyped[*testDoc](jpapi.NewJSONPatches( - jpapi.NewJSONPatchOrPanic(jpapi.ADD, ".foo", "baz", nil), - jpapi.NewJSONPatchOrPanic(jpapi.COPY, "baz.foobar", nil, ptr.To(".foo")), - jpapi.NewJSONPatchOrPanic(jpapi.REPLACE, "abc[2].c", 6, nil), - jpapi.NewJSONPatchOrPanic(jpapi.REMOVE, ".abc[1]", nil, nil), - )) + 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()) @@ -209,11 +208,11 @@ var _ = Describe("JSONPatch", func() { Context("API", func() { It("should be able to marshal and unmarshal JSONPatches", func() { - rawAPIPatches := []byte(`[{"op":"add","path":"/foo","value":{"foobar":"foobaz"},"from":"/bar"}]`) + 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(jpapi.NewJSONPatchOrPanic(jpapi.ADD, "/foo", map[string]any{"foobar": "foobaz"}, ptr.To("/bar")))) + 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)) @@ -222,3 +221,26 @@ var _ = Describe("JSONPatch", func() { }) }) + +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 +}