aboutsummaryrefslogtreecommitdiff
path: root/jsonmatch/interface.go
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2025-08-11 12:38:24 +0200
committerStefan Majewsky <majewsky@gmx.net>2025-08-11 12:38:50 +0200
commit2b4530f9c535027816aa46d06a6a565b97bd305c (patch)
treea01a1d36025aa43180486531b734905242b8c322 /jsonmatch/interface.go
parentd53f04ea869fb52c5d5d68a2032534bbaa27d120 (diff)
downloadgo-gg-2b4530f9c535027816aa46d06a6a565b97bd305c.tar.gz
add package jsonmatch
Diffstat (limited to 'jsonmatch/interface.go')
-rw-r--r--jsonmatch/interface.go279
1 files changed, 279 insertions, 0 deletions
diff --git a/jsonmatch/interface.go b/jsonmatch/interface.go
new file mode 100644
index 0000000..3c10cc3
--- /dev/null
+++ b/jsonmatch/interface.go
@@ -0,0 +1,279 @@
+// SPDX-FileCopyrightText: 2025 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+// Package jsonmatch implements matching of encoded JSON payloads against fixed assertions.
+// The interface is most suited for unit tests, and intended for functions that return encoded JSON payloads (such as HTTP API handlers).
+// Below is an example how package jsonmatch can be used together with only the standard library.
+//
+// In all likelihood, you will already have your own test assertion library to use on top of std.
+// Package jsonmatch is intended to be low-level enough to be easy to integrate with whatever assertion library you like to use.
+//
+// import (
+// "net/http"
+// "net/http/httptest"
+//
+// "github.com/majewsky/gg/jsonmatch"
+// )
+//
+// func TestJSONMatchOfResponseBody(t*testing.T) {
+// // this example assumes that the implementation being tested
+// // has an HTTP handler implementing GET /v1/things
+// var h http.Handler = buildAPIHandler()
+//
+// // use net/http/httptest to run a request
+// req := httptest.NewRequest(http.MethodGet, "/v1/things", nil)
+// resp := httptest.NewRecorder()
+// h.ServeHTTP(resp, req)
+// if resp.Code != http.StatusOK {
+// t.Error("unexpected error")
+// }
+//
+// // check that the response payload contains the data that we expect
+// expected := jsonmatch.Object{
+// "things": []jsonmatch.Object{
+// { "id": 1, "name": "First thing" },
+// { "id": 2, "name": "Second thing" },
+// },
+// }
+// for _, diff := range expected.DiffAgainst(resp.Body.Bytes()) {
+// if diff.Pointer == "" {
+// t.Errorf("%s: expected %s, but got %s", diff.Kind, diff.ExpectedJSON, diff.ActualJSON)
+// } else {
+// t.Errorf("%s at %s: expected %s, but got %s", diff.Kind, diff.Pointer, diff.ExpectedJSON, diff.ActualJSON)
+// }
+// }
+// }
+//
+// # Assertion format
+//
+// As shown in the example above, this package revolves around writing out assertions for how a JSON payload looks in your test's source code.
+//
+// expected := jsonmatch.Object{
+// "things": []jsonmatch.Object{
+// { "id": 1, "name": "First thing" },
+// { "id": 2, "name": "Second thing" },
+// },
+// "keywords": jsonmatch.Array{"example", "test"},
+// }
+// diffs := expected.DiffAgainst(actual)
+//
+// The example above demonstrates the recommended style:
+// - All scalar values in the assertion (bools, numbers, strings and nulls) use the respective predeclared Go value types.
+// - Objects use the jsonmatch.Object type instead of map[string]any.
+// - Arrays of only objects use the []jsonmatch.Object type.
+// - Other arrays use the jsonmatch.Array type instead of []any or more specific array/slice types.
+//
+// It is possible to write jsonmatch.Object as map[string]any and jsonmatch.Array as []any, like this:
+//
+// expected := map[string]any{
+// "things": []map[string]any{
+// { "id": 1, "name": "First thing" },
+// { "id": 2, "name": "Second thing" },
+// },
+// "keywords": []any{"example", "test"},
+// }
+// diffs := jsonmatch.Object(expected).DiffAgainst(actual)
+//
+// We do not recommend this style, as using the jsonmatch.Object and jsonmatch.Array identifiers better communicates the intent of the literal.
+//
+// # Recommendation: Do not use complex types in assertions
+//
+// We recommend avoiding more specific types than basic maps, slices and predeclared value types in the assertion.
+// It is tempting to reuse types from the implementation, but this risks repeating errors from the implementation in the test.
+// Consider the following example:
+//
+// // from the implementation
+// type Thing struct {
+// ID int `json:"id"`
+// Name string `json:"naem"`
+// }
+//
+// expected := jsonmatch.Object{
+// "things": []Thing{
+// { ID: 1, Name: "First thing" },
+// { ID: 2, Name: "Second thing" },
+// },
+// "keywords": jsonmatch.Array{"example", "test"},
+// }
+// diffs := expected.DiffAgainst(actual)
+//
+// In this example, we have made a mistake in the implementation.
+// The field "name" has been misspelled, so it will be marshalled as "naem" instead.
+// Because the test unmarshals into the same type as the implementation, it will not be able to uncover this error.
+// This example might be a bit contrived, but keeping test logic separate from implementation logic is especially important for types using advanced marshalling logic through custom implementations of the json.Marshaler and json.Unmarshaler interfaces.
+//
+// # Capturing nondeterministic data
+//
+// Sometimes, JSON payloads may contain randomly-generated fields like UUIDs or non-deterministic data like timestamps that cannot be predicted when writing the test code.
+// For these situations, package jsonmatch provides the CaptureField function.
+// The example below shows a test exercising a PUT endpoint to create an object, capturing the object's ID while asserting on the rest of the response, and then using that ID to exercise a GET endpoint that displays the created object.
+//
+// req1 := httptest.NewRequest(http.MethodPut, "/v1/things/new", strings.NewReader(`{"name":"hello"}`)
+// // ...
+//
+// var uuid string
+// diffs := jsonmatch.Object {
+// "thing": jsonmatch.Object {
+// "id": jsonmatch.CaptureField(&uuid),
+// "name": "hello",
+// },
+// }.DiffAgainst(resp1.Body.Bytes())
+// // ...
+//
+// req2 := httptest.NewRequest(http.MethodGet, "/v1/things")
+// // ...
+//
+// diffs = jsonmatch.Object {
+// "things": []jsonmatch.Object {
+// {
+// "id": uuid,
+// "name": "hello",
+// },
+// },
+// }.DiffAgainst(resp2.Body.Bytes())
+// // ...
+package jsonmatch
+
+import (
+ "encoding/json"
+ "errors"
+)
+
+// Diffable is the common interface of types Object, Array, Scalar and Null from this package.
+// The DiffAgainst function compares the value contained in the Diffable against an encoded JSON payload.
+//
+// The implementation will try to generate diffs as granularly as possible.
+// For example:
+//
+// expected := jsonmatch.Object{
+// "things": []jsonmatch.Object{
+// { "id": 1, "name": "First thing" },
+// { "id": 2, "name": "Second thing" },
+// },
+// }
+// actual := `{"things": [{"id": 1, "name": "First widget"}, {"id": 3, "name": "Second thing"}]}`
+// // this call...
+// diffs := expected.DiffAgainst(actual)
+// // ...will return something like this
+// diffs := []jsonmatch.Diff{
+// { Kind: "value mismatch", Pointer: "/things/0/name", ExpectedJSON: "First thing", ActualJSON: "First widget" },
+// { Kind: "value mismatch", Pointer: "/things/1/id", ExpectedJSON: "2", ActualJSON: "3" },
+// }
+//
+// However, the implementation will only recurse into substructures of the following well-known types: jsonmatch.Object, map[string]any, jsonmatch.Array, []any, []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.
+type Diffable interface {
+ DiffAgainst([]byte) []Diff
+}
+
+var (
+ _ Diffable = Array{}
+ _ Diffable = Object{}
+ _ Diffable = scalar{}
+)
+
+// Array implements diffing against an encoded JSON payload that is expected to contain an array.
+// Please refer to the package documentation for how to use this type.
+type Array []any
+
+// DiffAgainst implements the Diffable interface.
+func (a Array) DiffAgainst(buf []byte) []Diff {
+ return diffAgainst([]any(a), buf)
+}
+
+// Object implements diffing against an encoded JSON payload that is expected to contain an object.
+// Please refer to the package documentation for how to use this type.
+type Object map[string]any
+
+// DiffAgainst implements the Diffable interface.
+func (o Object) DiffAgainst(buf []byte) []Diff {
+ return diffAgainst(map[string]any(o), buf)
+}
+
+// Null implements diffing against an encoded JSON payload that is expected just the value `null`.
+// This type is only used on the top level of the JSON payload.
+// Within type Object or type Array, put a `nil` directly.
+func Null() Diffable {
+ return scalar{nil}
+}
+
+// Scalar implements diffing against an encoded JSON payload that is expected to contain just a scalar value (a number, string or boolean).
+// This type is only used on the top level of the JSON payload.
+// Within type Object or type Array, put the value directly.
+func Scalar[S ScalarValue](value S) Diffable {
+ return scalar{value}
+}
+
+type scalar struct {
+ Value any
+}
+
+// DiffAgainst implements the Diffable interface.
+func (s scalar) DiffAgainst(buf []byte) []Diff {
+ return diffAgainst(s.Value, buf)
+}
+
+// ScalarValue is an interface containing every type that can be given to func Scalar.
+type ScalarValue interface {
+ ~bool |
+ ~int | ~int8 | ~int16 | ~int32 | ~int64 |
+ ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
+ ~float32 | ~float64 |
+ ~string
+}
+
+// Diff is a difference between the actual encoded JSON payload given to a DiffAgainst() call, and the expectation encoded in the object that DiffAgainst() was called on.
+// See type Diffable for details on how diffing works.
+type Diff struct {
+ // Kind explains the type of difference.
+ // No stability guarantee is given for the values that can occur in this field.
+ // Values in this field are expected to read well when formatted using fmt.Sprintf("%s at %s", diff.Kind, diff.Pointer).
+ Kind string
+ // Pointer explains where the difference occurred within the Diffable.
+ // If ExpectedJSON and ActualJSON refer to the whole Diffable and the whole encoded JSON payload, then Pointer is the empty string.
+ Pointer Pointer
+ // A serialization of the respective part of the Diffable, or an error message or type description wrapped in <angle brackets>.
+ ExpectedJSON string
+ // A serialization of the respective part of the Diffable.
+ ActualJSON string
+}
+
+// Pointer is a JSON pointer (RFC 6901) that references a particular JSON value relative to the root of the encoded JSON payload that was given to DiffAgainst().
+// It appears in type Diff.
+//
+// This type is intended to become synonymous with encoding/json/jsontext.Pointer once that type is stabilized.
+type Pointer string
+
+// CaptureField returns a capture slot that can be placed in a jsonmatch.Object or jsonmatch.Array instance to capture individual non-deterministic values during an assertion.
+// Please refer to the package documentation for details and usage examples.
+//
+// Capture slots only work inside data structures that DiffAgainst() knows how to recurse into.
+// Please refer to the documentation on type Diffable for details.
+func CaptureField[T any](target *T) any {
+ // NOTE: The public interface is using generics because that allows enforcing
+ // that `target` is passed as pointer. But the internal representation holds
+ // `target` as `any` because not having type arguments on the capturedField
+ // type makes it easier to reflect on that type.
+ return capturedField{target}
+}
+
+type capturedField struct {
+ PointerToTarget any
+}
+
+// MarshalJSON implements the json.Marshaler interface by transparently marshaling the contained value.
+//
+// This implementation ensures that `capturedField` looks like its payload
+// when serialized for a "type mismatch" or "value mismatch" error message.
+func (f capturedField) MarshalJSON() ([]byte, error) {
+ return json.Marshal(f.PointerToTarget)
+}
+
+// UnmarshalJSON implements the json.Unmarshaler interface by always throwing an error.
+//
+// This implementation ensures that `capturedField` is not placed into a
+// container that DiffAgainst() does not know how to recurse into.
+func (f capturedField) UnmarshalJSON(buf []byte) error {
+ return errors.New("cannot unmarshal into jsonmatch.CaptureField()")
+}