package tide import ( "bytes" "encoding/json" "fmt" "mime" "net/http" "strconv" "strings" "unicode" "unicode/utf8" ) func compareBodies(want, got Response, step Step) []Diff { return compareBodiesWith(want, got, step, maskOptions{}) } func compareBodiesWith(want, got Response, step Step, opts maskOptions) []Diff { wantJSON := isJSONContentType(want.Headers) gotJSON := isJSONContentType(got.Headers) if wantJSON && gotJSON { wantRaw, wantDiffs := normalizeJSON([]byte(want.Body), step, opts) gotRaw, gotDiffs := normalizeJSON([]byte(got.Body), step, opts) 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 = "" } 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: ""}) 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: "", 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() }