complement/match/json.go

348 lines
12 KiB
Go

// package match contains matchers for HTTP and JSON data.
//
// Matchers are composable functions which check for the data specified, returning a golang error if a matcher fails.
// They are typically used with the 'must' package in the following way:
//
// res := user.Do(t, "GET", []string{"_matrix", "client", "v3", "rooms", roomID, "state", "m.room.server_acl"})
// must.MatchResponse(t, res, match.HTTPResponse{
// StatusCode: 200,
// JSON: []match.JSON{
// match.JSONKeyEqual("allow", []string{"*"}),
// match.JSONKeyEqual("deny", []string{"hs2"}),
// match.JSONKeyEqual("allow_ip_literals", true),
// },
// })
//
// Matchers have no concept of tests, and do not automatically fail tests if the match fails. This can be useful
// when you want to repeatedly perform a check until it succeeds (e.g from /sync). If you want matches to fail a test,
// you can use the 'must' package.
package match
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"github.com/tidwall/gjson"
)
// JSON will perform some matches on the given JSON body, returning an error on a mis-match.
// It can be assumed that the bytes are valid JSON.
type JSON func(body gjson.Result) error
// JSONKeyEqual returns a matcher which will check that `wantKey` is present and its value matches `wantValue`.
// `wantKey` can be nested, see https://godoc.org/github.com/tidwall/gjson#Get for details.
// `wantValue` is matched via JSONDeepEqual and the JSON takes the forms according to https://godoc.org/github.com/tidwall/gjson#Result.Value
func JSONKeyEqual(wantKey string, wantValue interface{}) JSON {
return func(body gjson.Result) error {
res := body
if wantKey != "" {
res = body.Get(wantKey)
}
if !res.Exists() {
return fmt.Errorf("key '%s' missing", wantKey)
}
gotValue := res.Value()
if !jsonDeepEqual([]byte(res.Raw), wantValue) {
return fmt.Errorf(
"key '%s' got '%v' (type %T) want '%v' (type %T)",
wantKey, gotValue, gotValue, wantValue, wantValue,
)
}
return nil
}
}
// JSONKeyPresent returns a matcher which will check that `wantKey` is present in the JSON object.
// `wantKey` can be nested, see https://godoc.org/github.com/tidwall/gjson#Get for details.
func JSONKeyPresent(wantKey string) JSON {
return func(body gjson.Result) error {
res := body.Get(wantKey)
if !res.Exists() {
return fmt.Errorf("key '%s' missing", wantKey)
}
return nil
}
}
// JSONKeyMissing returns a matcher which will check that `forbiddenKey` is not present in the JSON object.
// `forbiddenKey` can be nested, see https://godoc.org/github.com/tidwall/gjson#Get for details.
func JSONKeyMissing(forbiddenKey string) JSON {
return func(body gjson.Result) error {
res := body.Get(forbiddenKey)
if res.Exists() {
return fmt.Errorf("key '%s' present", forbiddenKey)
}
return nil
}
}
// JSONKeyTypeEqual returns a matcher which will check that `wantKey` is present and its value is of the type `wantType`.
// `wantKey` can be nested, see https://godoc.org/github.com/tidwall/gjson#Get for details.
func JSONKeyTypeEqual(wantKey string, wantType gjson.Type) JSON {
return func(body gjson.Result) error {
res := body.Get(wantKey)
if !res.Exists() {
return fmt.Errorf("key '%s' missing", wantKey)
}
if res.Type != wantType {
return fmt.Errorf("key '%s' is of the wrong type, got %s want %s", wantKey, res.Type, wantType)
}
return nil
}
}
// JSONKeyArrayOfSize returns a matcher which will check that `wantKey` is present and
// its value is an array with the given size.
// `wantKey` can be nested, see https://godoc.org/github.com/tidwall/gjson#Get for details.
func JSONKeyArrayOfSize(wantKey string, wantSize int) JSON {
return func(body gjson.Result) error {
res := body.Get(wantKey)
if !res.Exists() {
return fmt.Errorf("key '%s' missing", wantKey)
}
if !res.IsArray() {
return fmt.Errorf("key '%s' is not an array", wantKey)
}
entries := res.Array()
if len(entries) != wantSize {
return fmt.Errorf("key '%s' is an array of the wrong size, got %v want %v", wantKey, len(entries), wantSize)
}
return nil
}
}
type checkOffOpts struct {
allowUnwantedItems bool
mapper func(gjson.Result) interface{}
forEach func(interface{}, gjson.Result) error
}
// CheckOffAllowUnwanted allows unwanted items, that is items not in `wantItems`,
// to not fail the check.
func CheckOffAllowUnwanted() func(*checkOffOpts) {
return func(coo *checkOffOpts) {
coo.allowUnwantedItems = true
}
}
// CheckOffMapper maps each item /before/ continuing the check off process. This
// is useful to convert a gjson.Result to something more domain specific such as
// an event ID. For example, if `r` is a Matrix event, this allows `wantItems` to
// be a slice of event IDs:
//
// CheckOffMapper(func(r gjson.Result) interface{} {
// return r.Get("event_id").Str
// })
//
// The `mapper` function should map the item to an interface which will be
// comparable via JSONDeepEqual with items in `wantItems`.
func CheckOffMapper(mapper func(gjson.Result) interface{}) func(*checkOffOpts) {
return func(coo *checkOffOpts) {
coo.mapper = mapper
}
}
// CheckOffForEach does not change the check off logic, but instead passes each item
// to the provided function. If the function returns an error, the check fails.
// It is called with 2 args: the item being checked and the element itself
// (or value if it's an object).
func CheckOffForEach(forEach func(interface{}, gjson.Result) error) func(*checkOffOpts) {
return func(coo *checkOffOpts) {
coo.forEach = forEach
}
}
// EXPERIMENTAL
// JSONCheckOff returns a matcher which will loop over `wantKey` and ensure that the items
// (which can be array elements or object keys) are present exactly once in `wantItems`.
// This matcher can be used to check off items in an array/object.
//
// This function supports functional options which change the behaviour of the check off
// logic, see match.CheckOff... functions for more information.
//
// Usage: (ensures `events` has these events in any order, with the right event type)
//
// JSONCheckOff("events", []interface{}{"$foo:bar", "$baz:quuz"}, CheckOffMapper(func(r gjson.Result) interface{} {
// return r.Get("event_id").Str
// }), CheckOffForEach(func(eventID interface{}, eventBody gjson.Result) error {
// if eventBody.Get("type").Str != "m.room.message" {
// return fmt.Errorf("expected event to be 'm.room.message'")
// }
// }))
func JSONCheckOff(wantKey string, wantItems []interface{}, opts ...func(*checkOffOpts)) JSON {
var coo checkOffOpts
for _, opt := range opts {
opt(&coo)
}
return func(body gjson.Result) error {
res := body.Get(wantKey)
if !res.Exists() {
return fmt.Errorf("JSONCheckOff: missing key '%s'", wantKey)
}
if !res.IsArray() && !res.IsObject() {
return fmt.Errorf("JSONCheckOff: key '%s' is not an array or object", wantKey)
}
var err error
res.ForEach(func(key, val gjson.Result) bool {
itemRes := key
if res.IsArray() {
itemRes = val
}
var item interface{} = itemRes
if coo.mapper != nil {
// convert it to something we can check off
item = coo.mapper(itemRes)
if item == nil {
err = fmt.Errorf("JSONCheckOff(%s): mapper function mapped %v to nil", wantKey, itemRes.Raw)
return false
}
}
// check off the item
want := -1
for i, w := range wantItems {
wBytes, _ := json.Marshal(w)
if jsonDeepEqual(wBytes, item) {
want = i
break
}
}
if !coo.allowUnwantedItems && want == -1 {
err = fmt.Errorf("JSONCheckOff(%s): unexpected item %v (mapped value %v)", wantKey, itemRes.Raw, item)
return false
}
if want != -1 {
// delete the wanted item
wantItems = append(wantItems[:want], wantItems[want+1:]...)
}
// do further checks
if coo.forEach != nil {
err = coo.forEach(item, val)
if err != nil {
err = fmt.Errorf("JSONCheckOff(%s): forEach function returned an error for item %v: %w", wantKey, val, err)
return false
}
}
return true
})
// at this point we should have gone through all of wantItems.
// If we haven't then we expected to see some items but didn't.
if err == nil && len(wantItems) > 0 {
err = fmt.Errorf("JSONCheckOff(%s): did not see items: %v", wantKey, wantItems)
}
return err
}
}
// DEPRECATED: Prefer JSONCheckOff as this uses functional options which makes params easier to understand.
//
// JSONCheckOff returns a matcher which will loop over `wantKey` and ensure that the items
// (which can be array elements or object keys)
// are present exactly once in any order in `wantItems`. If there are unexpected items or items
// appear more than once then the match fails. This matcher can be used to check off items in
// an array/object. The `mapper` function should map the item to an interface which will be
// comparable via JSONDeepEqual with items in `wantItems`. The optional `fn` callback
// allows more checks to be performed other than checking off the item from the list. It is
// called with 2 args: the result of the `mapper` function and the element itself (or value if
// it's an object).
//
// Usage: (ensures `events` has these events in any order, with the right event type)
//
// JSONCheckOff("events", []interface{}{"$foo:bar", "$baz:quuz"}, func(r gjson.Result) interface{} {
// return r.Get("event_id").Str
// }, func(eventID interface{}, eventBody gjson.Result) error {
// if eventBody.Get("type").Str != "m.room.message" {
// return fmt.Errorf("expected event to be 'm.room.message'")
// }
// })
func JSONCheckOffDeprecated(wantKey string, wantItems []interface{}, mapper func(gjson.Result) interface{}, fn func(interface{}, gjson.Result) error) JSON {
return JSONCheckOff(wantKey, wantItems, CheckOffMapper(mapper), CheckOffForEach(fn))
}
// JSONArrayEach returns a matcher which will check that `wantKey` is an array then loops over each
// item calling `fn`. If `fn` returns an error, iterating stops and an error is returned.
func JSONArrayEach(wantKey string, fn func(gjson.Result) error) JSON {
return func(body gjson.Result) error {
if wantKey != "" {
body = body.Get(wantKey)
}
if !body.Exists() {
return fmt.Errorf("JSONArrayEach: missing key '%s'", wantKey)
}
if !body.IsArray() {
return fmt.Errorf("JSONArrayEach: key '%s' is not an array", wantKey)
}
var err error
body.ForEach(func(_, val gjson.Result) bool {
err = fn(val)
return err == nil
})
return err
}
}
// JSONMapEach returns a matcher which will check that `wantKey` is a map then loops over each
// item calling `fn`. If `fn` returns an error, iterating stops and an error is returned.
func JSONMapEach(wantKey string, fn func(k, v gjson.Result) error) JSON {
return func(body gjson.Result) error {
res := body.Get(wantKey)
if !res.Exists() {
return fmt.Errorf("JSONMapEach: missing key '%s'", wantKey)
}
if !res.IsObject() {
return fmt.Errorf("JSONMapEach: key '%s' is not an object", wantKey)
}
var err error
res.ForEach(func(key, val gjson.Result) bool {
err = fn(key, val)
return err == nil
})
return err
}
}
// EXPERIMENTAL
// AnyOf takes 1 or more `checkers`, and builds a new checker which accepts a given
// json body iff it's accepted by at least one of the original `checkers`.
func AnyOf(checkers ...JSON) JSON {
return func(body gjson.Result) error {
if len(checkers) == 0 {
return fmt.Errorf("must provide at least one checker to AnyOf")
}
errors := make([]error, len(checkers))
for i, check := range checkers {
errors[i] = check(body)
if errors[i] == nil {
return nil
}
}
builder := strings.Builder{}
builder.WriteString("all checks failed:")
for _, err := range errors {
builder.WriteString("\n ")
builder.WriteString(err.Error())
}
return fmt.Errorf(builder.String())
}
}
// jsonDeepEqual compares raw json with a json-serializable value, seeing if they're equal.
// It forces `gotJson` through a JSON parser to ensure keys/whitespace are identical to the marshalled form of `wantValue`.
func jsonDeepEqual(gotJson []byte, wantValue interface{}) bool {
// marshal what the test gave us
wantBytes, _ := json.Marshal(wantValue)
// re-marshal what the network gave us to acount for key ordering
var gotVal interface{}
_ = json.Unmarshal(gotJson, &gotVal)
gotBytes, _ := json.Marshal(gotVal)
return bytes.Equal(gotBytes, wantBytes)
}