package main import ( "encoding/json" "flag" "fmt" "io" "net/http" "os" "strings" "time" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" ) // /api/version is intentionally excluded — it returns each binary's own build // identity (tag/commit/build_date), which differs by design between independently // built backends. Pass -route /api/version to inspect it manually. var allRoutes = []string{"/api/adults", "/api/juniors", "/api/payments"} // defaultAllowlist holds dotted key paths to strip before diffing. // render_time.total is forward-compatible insurance: today it lives in the Jinja // template context only (app.py inject_render_time) and is logged via // middleware/timer.go on the Go side, so it isn't in any JSON response. If either // side ever surfaces it under a render_time envelope, the scrubber handles it. var defaultAllowlist = []string{"render_time.total"} func main() { pyURL := flag.String("py", "http://localhost:5001", "Python backend base URL") goURL := flag.String("go", "http://localhost:8080", "Go backend base URL") route := flag.String("route", "", "single route to diff, e.g. /api/adults (default: all)") timeout := flag.Duration("timeout", 30*time.Second, "per-request HTTP timeout") flag.Parse() client := &http.Client{Timeout: *timeout} targets := allRoutes if *route != "" { targets = []string{*route} } matched, diffs, errs := 0, 0, 0 for _, r := range targets { pyMap, err1 := fetch(client, *pyURL+r) goMap, err2 := fetch(client, *goURL+r) if err1 != nil || err2 != nil { fmt.Printf("=== %s ===\n", r) if err1 != nil { fmt.Printf("ERROR (py): %v\n", err1) } if err2 != nil { fmt.Printf("ERROR (go): %v\n", err2) } fmt.Println() errs++ continue } scrub(pyMap, defaultAllowlist) scrub(goMap, defaultAllowlist) diff := cmp.Diff(pyMap, goMap, cmpopts.EquateEmpty()) if diff == "" { fmt.Printf("=== %s ===\nOK\n\n", r) matched++ } else { fmt.Printf("=== %s ===\n%s\n", r, diff) diffs++ } } total := len(targets) fmt.Printf("parity: %d/%d routes match", matched, total) if diffs > 0 { fmt.Printf(", %d diff", diffs) if diffs > 1 { fmt.Print("s") } } if errs > 0 { fmt.Printf(", %d error", errs) if errs > 1 { fmt.Print("s") } } fmt.Println() if errs > 0 { os.Exit(2) } if diffs > 0 { os.Exit(1) } } func fetch(client *http.Client, url string) (map[string]any, error) { resp, err := client.Get(url) //nolint:noctx if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read body: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } var m map[string]any if err := json.Unmarshal(body, &m); err != nil { return nil, fmt.Errorf("decode JSON: %w", err) } return m, nil } // scrub removes keys from m whose dotted paths appear in paths. // A bare segment (no dot) deletes a top-level key. // A two-segment path "parent.child" deletes child from m["parent"] if it is a map. func scrub(m map[string]any, paths []string) { for _, path := range paths { parts := strings.SplitN(path, ".", 2) if len(parts) == 1 { delete(m, parts[0]) } else { if child, ok := m[parts[0]].(map[string]any); ok { delete(child, parts[1]) } } } }