aboutsummaryrefslogtreecommitdiff
path: root/jsonmatch
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2026-06-04 16:13:13 +0200
committerStefan Majewsky <majewsky@gmx.net>2026-06-04 16:13:13 +0200
commit2d71e70097ba48d5c1e59f455bd2499ab14844e3 (patch)
tree5a6cebf4dfc87620efbcd7dd164252c37c688898 /jsonmatch
parentde1908cc7a11076f808f19f2d3ce115cda4bccd4 (diff)
downloadgo-gg-2d71e70097ba48d5c1e59f455bd2499ab14844e3.tar.gz
jsonmatch: allow embedding custom Diffable instances within Object and Array
Diffstat (limited to 'jsonmatch')
-rw-r--r--jsonmatch/diff_test.go34
-rw-r--r--jsonmatch/interface.go11
-rw-r--r--jsonmatch/machinery.go30
3 files changed, 72 insertions, 3 deletions
diff --git a/jsonmatch/diff_test.go b/jsonmatch/diff_test.go
index 1a49010..aba2d00 100644
--- a/jsonmatch/diff_test.go
+++ b/jsonmatch/diff_test.go
@@ -409,3 +409,37 @@ func TestArrayOnArrayAction(t *testing.T) {
match = jsonmatch.Array{jsonmatch.Array{1, 2}}
AssertEqual(t, match.DiffAgainst(message), expected)
}
+
+func TestDispatchIntoCustomDiffable(t *testing.T) {
+ message := []byte(`{"name":"data.json","type":"application/json","content":"{\"foo\":1,\"bar\":3}"}`)
+ match := jsonmatch.Object{
+ "name": "data.json",
+ "type": "application/json",
+ "content": jsonWithinJSONString{jsonmatch.Object{
+ "foo": 1,
+ "bar": 2,
+ }},
+ }
+ expected := []jsonmatch.Diff{{
+ Kind: "value mismatch",
+ Pointer: "/content/bar",
+ ExpectedJSON: "2",
+ ActualJSON: "3",
+ }}
+ AssertEqual(t, match.DiffAgainst(message), expected)
+}
+
+// jsonWithinJSONString appears in TestDispatchIntoCustomDiffable.
+type jsonWithinJSONString struct {
+ inner jsonmatch.Diffable
+}
+
+// DiffAgainst implements the DiffAgainst interface.
+func (j jsonWithinJSONString) DiffAgainst(buf []byte) []jsonmatch.Diff {
+ var s string
+ err := json.Unmarshal(buf, &s)
+ if err != nil {
+ panic(err.Error())
+ }
+ return j.inner.DiffAgainst([]byte(s))
+}
diff --git a/jsonmatch/interface.go b/jsonmatch/interface.go
index d7b6b5b..f175f59 100644
--- a/jsonmatch/interface.go
+++ b/jsonmatch/interface.go
@@ -128,6 +128,13 @@
// },
// }.DiffAgainst(resp2.Body.Bytes())
// // ...
+//
+// # TODO
+//
+// As a special case, [json.RawMessage] may appear on the "expected" side to match string values that contain JSON themselves.
+// For example:
+//
+// actual := `{"name":"data.json","type":"application/json","content":"{\"foo\":2"}`
package jsonmatch // import "go.xyrillian.de/gg/jsonmatch"
import (
@@ -160,6 +167,10 @@ import (
// However, the implementation will only recurse into substructures of the following well-known types: jsonmatch.Object, map[string]any, jsonmatch.Array, []any, []jsonmatch.Object, []map[string]any.
// Any other map, array, slice, struct or pointer type will be treated as a black box:
// If its JSON serialization differs from that of the respective section of the actual payload, a diff will be generated for its entirety only, not for any specific subfields.
+//
+// As an exception, if a substructure is of a foreign type that implements the Diffable interface, its DiffAgainst() method will be called by reserializing the actual payload.
+// This is usually not what you want: Most of the time, it is much easier to have helper functions generate instances of the standard Diffable types.
+// This extension interface is intended only for bizarre encodings (e.g. JSON payloads within JSON strings that jsonmatch itself would not be able to inspect).
type Diffable interface {
DiffAgainst([]byte) []Diff
}
diff --git a/jsonmatch/machinery.go b/jsonmatch/machinery.go
index f3f5348..a10251a 100644
--- a/jsonmatch/machinery.go
+++ b/jsonmatch/machinery.go
@@ -26,7 +26,7 @@ func marshalExpectedForDiff(value any) string {
func marshalActualForDiff(value any) string {
// `actual` values are always safe to marshal because they were
- // unmarshaled from JSON into any and thus can only contain safe
+ // unmarshaled from JSON into any and thus can only contain safe types
buf, err := json.Marshal(value)
if err != nil {
// this line is therefore unreachable in tests and only exists as defense in depth
@@ -121,8 +121,9 @@ func keyIntoPointerFragment(key string) string {
}
const (
- kindValueMismatch = "value mismatch"
- kindTypeMismatch = "type mismatch"
+ kindValueMismatch = "value mismatch"
+ kindTypeMismatch = "type mismatch"
+ kindDispatchFailed = "dispatch failed"
)
// NOTE: getDiffsForValue is the main part of the recursion to generate the diff.
@@ -168,6 +169,29 @@ func getDiffsForValue(path []pathElement, expected, actual any) []Diff {
}
}
+ // generic handling for custom Diffables
+ // (if any unexpected error occurs here, we fall back to the default handling)
+ if diffable, ok := expected.(Diffable); ok {
+ // `actual` values are always safe to marshal because they were
+ // unmarshaled from JSON into any and thus can only contain safe types
+ buf, err := json.Marshal(actual)
+ if err != nil {
+ // this branch is therefore unreachable in tests and only exists as defense in depth
+ return []Diff{{
+ Kind: kindDispatchFailed,
+ Pointer: pathIntoPointer(path),
+ ExpectedJSON: fmt.Sprintf("<custom diffable: %#v>", diffable),
+ ActualJSON: fmt.Sprintf("<marshal error: %s>", err.Error()),
+ }}
+ }
+ diffs := diffable.DiffAgainst(buf)
+ for idx, diff := range diffs {
+ diff.Pointer = pathIntoPointer(path) + diff.Pointer
+ diffs[idx] = diff
+ }
+ return diffs
+ }
+
// generic handling for values or structures that we do not recurse into further:
// check that `expected` encodes to JSON in an equivalent way to `actual`
actualJSON := marshalActualForDiff(actual)