289 lines
6.2 KiB
Go
289 lines
6.2 KiB
Go
package tide
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"mime"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
func compareBodies(want, got Response, step Step) []Diff {
|
|
wantJSON := isJSONContentType(want.Headers)
|
|
gotJSON := isJSONContentType(got.Headers)
|
|
if wantJSON && gotJSON {
|
|
wantRaw, wantDiffs := normalizeJSON([]byte(want.Body), step)
|
|
gotRaw, gotDiffs := normalizeJSON([]byte(got.Body), step)
|
|
diffs := append([]Diff{}, wantDiffs...)
|
|
diffs = append(diffs, gotDiffs...)
|
|
diffs = append(diffs, diffJSON(wantRaw, gotRaw)...)
|
|
return diffs
|
|
}
|
|
return diffBytes([]byte(want.Body), []byte(got.Body))
|
|
}
|
|
|
|
var globalCompareHeaders = []string{
|
|
"Content-Type",
|
|
"X-Total-Count",
|
|
"Access-Control-Allow-Origin",
|
|
"Access-Control-Allow-Credentials",
|
|
"Access-Control-Allow-Headers",
|
|
"Access-Control-Allow-Methods",
|
|
"Access-Control-Expose-Headers",
|
|
}
|
|
|
|
var extraCompareHeaders = []string{
|
|
"Cache-Control",
|
|
"Pragma",
|
|
"WWW-Authenticate",
|
|
"Content-Disposition",
|
|
"Link",
|
|
"Location",
|
|
}
|
|
|
|
var neverCompareHeaders = []string{
|
|
"Date",
|
|
"Server",
|
|
"X-Request-Id",
|
|
"X-Request-ID",
|
|
"X-Correlation-Id",
|
|
}
|
|
|
|
func compareHeaders(want, got, extra map[string]string) []Diff {
|
|
allow := map[string]bool{}
|
|
for _, n := range globalCompareHeaders {
|
|
allow[http.CanonicalHeaderKey(n)] = true
|
|
}
|
|
for _, n := range extraCompareHeaders {
|
|
allow[http.CanonicalHeaderKey(n)] = true
|
|
}
|
|
for n := range extra {
|
|
allow[http.CanonicalHeaderKey(n)] = true
|
|
}
|
|
for _, n := range neverCompareHeaders {
|
|
delete(allow, http.CanonicalHeaderKey(n))
|
|
}
|
|
var diffs []Diff
|
|
for k, wv := range want {
|
|
ck := http.CanonicalHeaderKey(k)
|
|
if !allow[ck] {
|
|
continue
|
|
}
|
|
gv := headerValue(got, k)
|
|
if gv == wv {
|
|
continue
|
|
}
|
|
if gv == "" {
|
|
gv = "<missing>"
|
|
}
|
|
diffs = append(diffs, Diff{Path: "header." + ck, Expected: wv, Actual: gv})
|
|
}
|
|
return diffs
|
|
}
|
|
|
|
func isJSONContentType(headers map[string]string) bool {
|
|
ct := headerValue(headers, "Content-Type")
|
|
if ct == "" {
|
|
return false
|
|
}
|
|
media, _, err := mime.ParseMediaType(ct)
|
|
if err != nil {
|
|
return strings.Contains(strings.ToLower(ct), "json")
|
|
}
|
|
return media == "application/json" || strings.HasSuffix(media, "+json")
|
|
}
|
|
|
|
func headerValue(headers map[string]string, name string) string {
|
|
if v, ok := headers[name]; ok {
|
|
return v
|
|
}
|
|
for k, v := range headers {
|
|
if strings.EqualFold(k, name) {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func diffJSON(want, got []byte) []Diff {
|
|
wantVal, err := decodeJSON(want)
|
|
if err != nil {
|
|
return []Diff{{Path: "$", Expected: "valid JSON", Actual: err.Error()}}
|
|
}
|
|
gotVal, err := decodeJSON(got)
|
|
if err != nil {
|
|
return []Diff{{Path: "$", Expected: formatValue(wantVal), Actual: err.Error()}}
|
|
}
|
|
var diffs []Diff
|
|
compareValue("$", wantVal, gotVal, &diffs)
|
|
return diffs
|
|
}
|
|
|
|
func decodeJSON(raw []byte) (any, error) {
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
dec.UseNumber()
|
|
var v any
|
|
if err := dec.Decode(&v); err != nil {
|
|
return nil, err
|
|
}
|
|
if dec.More() {
|
|
return nil, fmt.Errorf("trailing JSON after first value")
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
func compareValue(path string, want, got any, diffs *[]Diff) {
|
|
switch w := want.(type) {
|
|
case map[string]any:
|
|
g, ok := got.(map[string]any)
|
|
if !ok {
|
|
*diffs = append(*diffs, Diff{Path: path, Expected: formatValue(want), Actual: formatValue(got)})
|
|
return
|
|
}
|
|
for k, wv := range w {
|
|
gv, exists := g[k]
|
|
child := pathJoin(path, k)
|
|
if !exists {
|
|
*diffs = append(*diffs, Diff{Path: child, Expected: formatValue(wv), Actual: "<missing>"})
|
|
continue
|
|
}
|
|
compareValue(child, wv, gv, diffs)
|
|
}
|
|
for k, gv := range g {
|
|
if _, exists := w[k]; !exists {
|
|
*diffs = append(*diffs, Diff{Path: pathJoin(path, k), Expected: "<missing>", Actual: formatValue(gv)})
|
|
}
|
|
}
|
|
case []any:
|
|
g, ok := got.([]any)
|
|
if !ok {
|
|
*diffs = append(*diffs, Diff{Path: path, Expected: formatValue(want), Actual: formatValue(got)})
|
|
return
|
|
}
|
|
if len(w) != len(g) {
|
|
*diffs = append(*diffs, Diff{
|
|
Path: path,
|
|
Expected: fmt.Sprintf("array[%d]", len(w)),
|
|
Actual: fmt.Sprintf("array[%d]", len(g)),
|
|
})
|
|
}
|
|
n := min(len(w), len(g))
|
|
for i := 0; i < n; i++ {
|
|
compareValue(fmt.Sprintf("%s[%d]", path, i), w[i], g[i], diffs)
|
|
}
|
|
default:
|
|
if !scalarEqual(want, got) {
|
|
*diffs = append(*diffs, Diff{Path: path, Expected: formatValue(want), Actual: formatValue(got)})
|
|
}
|
|
}
|
|
}
|
|
|
|
func scalarEqual(want, got any) bool {
|
|
if want == nil || got == nil {
|
|
return want == nil && got == nil
|
|
}
|
|
switch w := want.(type) {
|
|
case json.Number:
|
|
g, ok := got.(json.Number)
|
|
return ok && w == g
|
|
case string:
|
|
g, ok := got.(string)
|
|
return ok && w == g
|
|
case bool:
|
|
g, ok := got.(bool)
|
|
return ok && w == g
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func formatValue(v any) string {
|
|
switch t := v.(type) {
|
|
case nil:
|
|
return "null"
|
|
case json.Number:
|
|
return "number " + string(t)
|
|
case string:
|
|
return strconv.Quote(t)
|
|
case bool:
|
|
return fmt.Sprintf("%v", t)
|
|
case map[string]any:
|
|
return "object"
|
|
case []any:
|
|
return fmt.Sprintf("array[%d]", len(t))
|
|
default:
|
|
return fmt.Sprintf("%v", t)
|
|
}
|
|
}
|
|
|
|
func pathJoin(parent, key string) string {
|
|
if parent == "$" {
|
|
return "$." + key
|
|
}
|
|
return parent + "." + key
|
|
}
|
|
|
|
func diffBytes(want, got []byte) []Diff {
|
|
n := min(len(want), len(got))
|
|
off := n
|
|
for i := 0; i < n; i++ {
|
|
if want[i] != got[i] {
|
|
off = i
|
|
break
|
|
}
|
|
}
|
|
if off == n && len(want) == len(got) {
|
|
return nil
|
|
}
|
|
return []Diff{{
|
|
Path: fmt.Sprintf("body[%d]", off),
|
|
Expected: printableWindow(want, off),
|
|
Actual: printableWindow(got, off),
|
|
Offset: off,
|
|
Byte: true,
|
|
}}
|
|
}
|
|
|
|
func printableWindow(b []byte, off int) string {
|
|
if len(b) == 0 {
|
|
return `""`
|
|
}
|
|
start := off - 8
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
end := off + 8
|
|
if end > len(b) {
|
|
end = len(b)
|
|
}
|
|
return quotePrintable(b[start:end])
|
|
}
|
|
|
|
func quotePrintable(b []byte) string {
|
|
var buf strings.Builder
|
|
buf.WriteByte('"')
|
|
for i := 0; i < len(b); {
|
|
r, size := utf8.DecodeRune(b[i:])
|
|
if r == utf8.RuneError && size == 1 {
|
|
fmt.Fprintf(&buf, "\\x%02x", b[i])
|
|
i++
|
|
continue
|
|
}
|
|
if r == '\\' || r == '"' {
|
|
buf.WriteByte('\\')
|
|
buf.WriteRune(r)
|
|
} else if unicode.IsPrint(r) {
|
|
buf.WriteRune(r)
|
|
} else {
|
|
fmt.Fprintf(&buf, "\\u%04x", r)
|
|
}
|
|
i += size
|
|
}
|
|
buf.WriteByte('"')
|
|
return buf.String()
|
|
}
|