Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix json diff #120

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 182 additions & 62 deletions compare/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,88 +2,208 @@ package compare

import (
"encoding/json"

"github.com/yudai/gojsondiff"
"github.com/yudai/gojsondiff/formatter"
"fmt"
"reflect"
)

// JSON compares two json strings, processes them to handle wild cards, and returns
// the prettified JSON string
func JSON(a []byte, b []byte, colorize bool) (Comparison, error) {
c := Comparison{errorPrefix: "JSON: Difference between expected output and received output"}
differ := gojsondiff.New()
d, err := differ.Compare(a, b)
if err != nil {
return c, err
}
// JSON compares JSON in `a` against `b`.
// If a key in `b` has value "*", that key automatically "matches" any value in `a`.
func JSON(a, b []byte, colorize bool) (Comparison, error) {
var objA, objB interface{}

// filter the fields we do not want
filteredDiffer := jsondiff{
deltas: filter(d.Deltas(), filterWildcards),
// Unmarshal JSON a -> objA
if err := json.Unmarshal(a, &objA); err != nil {
return Comparison{}, fmt.Errorf("failed to unmarshal JSON a: %w", err)
}
// Unmarshal JSON b -> objB
if err := json.Unmarshal(b, &objB); err != nil {
return Comparison{}, fmt.Errorf("failed to unmarshal JSON b: %w", err)
}

if filteredDiffer.Modified() {
var aJSON map[string]interface{}
json.Unmarshal(a, &aJSON)
// Normalize the JSON structures (e.g. decode numbers into float64 consistently, etc.)
normalizedA := normalize(objA)
normalizedB := normalize(objB)

formatter := formatter.NewAsciiFormatter(aJSON, formatter.AsciiFormatterConfig{
ShowArrayIndex: false,
Coloring: colorize,
})
// Compare the two structures and track mismatches.
mismatches := compareValues("", normalizedA, normalizedB, colorize)

var err error
if c.output, err = formatter.Format(filteredDiffer); err != nil {
return c, err
}
var comp Comparison
if len(mismatches) == 0 {
// Everything matched

// comp.output = color(fmt.Sprintf("✓ JSON matches\n"), colorize, "green")
} else {
// There were mismatches
comp.errorPrefix = color("MISMATCHES FOUND:", colorize, "red")
comp.output = joinLines(mismatches)
}
return c, nil
}

// Implement to gojsondiff Differ interface
type jsondiff struct {
deltas []gojsondiff.Delta
return comp, nil
}

func (j jsondiff) Deltas() []gojsondiff.Delta {
return j.deltas
// normalize helps convert all JSON-decoded structures into map[string]interface{},
// []interface{}, float64, string, bool, or nil for consistency.
func normalize(v interface{}) interface{} {
switch val := v.(type) {
case map[string]interface{}:
// Normalize each entry in the map
m := make(map[string]interface{})
for k, subVal := range val {
m[k] = normalize(subVal)
}
return m
case []interface{}:
// Normalize each element in the slice
arr := make([]interface{}, len(val))
for i, subVal := range val {
arr[i] = normalize(subVal)
}
return arr
default:
// string, float64, bool, nil remain as is
return val
}
}

func (j jsondiff) Modified() bool {
return len(j.deltas) > 0
}
// compareValues recursively compares a vs b.
// If b is the string "*", it's considered a wildcard => always matches.
// Returns a list of mismatch descriptions (empty if all is good).
func compareValues(path string, a, b interface{}, colorize bool) []string {
// If b is literally "*", we ignore a's value and pass.
if str, ok := b.(string); ok && str == "*" {
return nil
}

// Type-check
typeA := reflect.TypeOf(a)
typeB := reflect.TypeOf(b)

if typeA != typeB {
return []string{
color(fmt.Sprintf("%s: type mismatch (got %T vs %T)", path, a, b),
colorize, "red"),
}
}

// filter each delta using the passed filterfunc
func filter(deltas []gojsondiff.Delta, f func(gojsondiff.Delta) bool) []gojsondiff.Delta {
filtered := make([]gojsondiff.Delta, 0)
for _, delta := range deltas {
switch d := delta.(type) {
case *gojsondiff.Object:
d.Deltas = filter(d.Deltas, f)
filtered = append(filtered, d)
case *gojsondiff.Array:
d.Deltas = filter(d.Deltas, f)
filtered = append(filtered, d)
default:
if f(d) {
filtered = append(filtered, d)
switch valB := b.(type) {
// Compare map => map
case map[string]interface{}:
valA := a.(map[string]interface{})
return compareMaps(path, valA, valB, colorize)
// Compare slice => slice
case []interface{}:
valA := a.([]interface{})
return compareSlices(path, valA, valB, colorize)
// Compare primitive values (string, float64, bool, nil)
default:
if !reflect.DeepEqual(a, b) {
return []string{
color(fmt.Sprintf("%s: value mismatch (got %v vs %v)", path, a, b),
colorize, "red"),
}
}
}
return filtered

return nil
}

const (
wildcard = "*"
)
func compareMaps(path string, a, b map[string]interface{}, colorize bool) []string {
mismatches := make([]string, 0)

// filterWildcards removes and "modified" fields where the new value is a wildcard
func filterWildcards(delta gojsondiff.Delta) bool {
switch delta.(type) {
case *gojsondiff.Modified:
d := delta.(*gojsondiff.Modified)
if v, ok := d.NewValue.(string); ok && v == wildcard {
return false
// Check all keys in b
for k, valB := range b {
newPath := makePath(path, k)
valA, ok := a[k]
if !ok {
// Key is missing in a
mismatches = append(mismatches,
color(fmt.Sprintf("%s: key missing in JSON a", newPath), colorize, "red"))
continue
}
// Compare recursively
subMismatches := compareValues(newPath, valA, valB, colorize)
mismatches = append(mismatches, subMismatches...)
}

// If you also want to detect extra keys in `a` that are not in `b`,
// you can loop over `a`'s keys here:
/*
for k := range a {
if _, ok := b[k]; !ok {
newPath := makePath(path, k)
mismatches = append(mismatches,
color(fmt.Sprintf("%s: extra key in JSON a not in b", newPath), colorize, "yellow"))
}
}
*/

return mismatches
}

func compareSlices(path string, a, b []interface{}, colorize bool) []string {
mismatches := make([]string, 0)

// Compare length first (if you want an exact match in slice length)
if len(a) != len(b) {
mismatches = append(mismatches,
color(fmt.Sprintf("%s: slice length mismatch (got %d vs %d)",
path, len(a), len(b)), colorize, "red"))
// Optionally return here or keep comparing smaller range
// return mismatches
}

// Compare element by element (up to the min length)
minLen := len(a)
if len(b) < minLen {
minLen = len(b)
}

for i := 0; i < minLen; i++ {
newPath := fmt.Sprintf("%s[%d]", path, i)
subMismatches := compareValues(newPath, a[i], b[i], colorize)
mismatches = append(mismatches, subMismatches...)
}

return mismatches
}

// makePath helper to build a dotted path notation
func makePath(base, next string) string {
if base == "" {
return next
}
return base + "." + next
}

// color applies ANSI color codes if colorize is true.
func color(text string, colorize bool, colorName string) string {
if !colorize {
return text
}

var code string
switch colorName {
case "red":
code = "\033[31m"
case "green":
code = "\033[32m"
case "yellow":
code = "\033[33m"
default:
code = "\033[0m"
}
reset := "\033[0m"
return code + text + reset
}

// joinLines helper to turn a slice of strings into a single output
func joinLines(lines []string) string {
if len(lines) == 0 {
return ""
}
out := ""
for _, l := range lines {
out += l + "\n"
}
return true
return out
}
Loading