package main import ( "bytes" "encoding/json" "encoding/xml" "flag" "fmt" "html" "io" "os" "regexp" "strings" "github.com/sergi/go-diff/diffmatchpatch" ) func main() { htmlOutput := flag.String("html", "", "Path to save HTML diff report") flag.Parse() args := flag.Args() if len(args) < 2 { fmt.Println("Usage: http-diff [options] ") fmt.Println("Options:") flag.PrintDefaults() os.Exit(1) } file1 := args[0] file2 := args[1] body1, err := extractBody(file1) if err != nil { fmt.Printf("Error extracting body from %s: %v\n", file1, err) os.Exit(1) } body2, err := extractBody(file2) if err != nil { fmt.Printf("Error extracting body from %s: %v\n", file2, err) os.Exit(1) } norm1 := normalize(body1) norm2 := normalize(body2) dmp := diffmatchpatch.New() diffs := dmp.DiffMain(norm1, norm2, false) lineDiffs := dmp.DiffCleanupSemantic(diffs) if *htmlOutput != "" { err := generateHTML(*htmlOutput, file1, file2, lineDiffs) if err != nil { fmt.Printf("Error generating HTML: %v\n", err) os.Exit(1) } fmt.Printf("HTML report generated: %s\n", *htmlOutput) return } // Custom line-by-line diff for better readability for _, diff := range lineDiffs { switch diff.Type { case diffmatchpatch.DiffInsert: lines := strings.Split(diff.Text, "\n") for _, line := range lines { if line != "" { fmt.Printf("+ %s\n", line) } } case diffmatchpatch.DiffDelete: lines := strings.Split(diff.Text, "\n") for _, line := range lines { if line != "" { fmt.Printf("- %s\n", line) } } case diffmatchpatch.DiffEqual: // Optionally skip unchanged lines or show context lines := strings.Split(diff.Text, "\n") // Filter out empty lines from splitting var cleanLines []string for _, l := range lines { if strings.TrimSpace(l) != "" { cleanLines = append(cleanLines, l) } } if len(cleanLines) > 6 { fmt.Printf(" %s\n", cleanLines[0]) fmt.Printf(" %s\n", cleanLines[1]) fmt.Printf(" ...\n") fmt.Printf(" %s\n", cleanLines[len(cleanLines)-2]) fmt.Printf(" %s\n", cleanLines[len(cleanLines)-1]) } else { for _, line := range cleanLines { fmt.Printf(" %s\n", line) } } } } } func extractBody(path string) (string, error) { content, err := os.ReadFile(path) if err != nil { return "", err } // Use a non-greedy regex to find the LAST /* ... */ block which typically contains the body re := regexp.MustCompile(`(?s)/\*\s*(<\?xml.*?|\{.*?|\[.*?)\s*\*/`) matches := re.FindAllStringSubmatch(string(content), -1) if len(matches) > 0 { // Return the last match as it's more likely to be the response body lastMatch := matches[len(matches)-1] return strings.TrimSpace(lastMatch[1]), nil } return "", fmt.Errorf("could not find body in /* ... */ block") } func normalize(body string) string { body = strings.TrimSpace(body) if body == "" { return "" } // Mask common timestamps and changing fields timestampRegex := regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})`) body = timestampRegex.ReplaceAllString(body, "[TIMESTAMP]") // Mask account IDs if they are variable, but usually they match in these files. // Let's stick to timestamps for now. // Try XML first if strings.HasPrefix(body, " or tagsToMask := []string{"updatedOn", "createdOn", "lastModified", "timestamp"} for _, tag := range tagsToMask { re := regexp.MustCompile(fmt.Sprintf(`<%s>.*?`, tag, tag)) body = re.ReplaceAllString(body, fmt.Sprintf("<%s>[MASKED]", tag, tag)) } // Also mask empty attributes that might be noisy, like displayName="" body = regexp.MustCompile(`\s+displayName=""`).ReplaceAllString(body, "") var out bytes.Buffer decoder := xml.NewDecoder(strings.NewReader(body)) encoder := xml.NewEncoder(&out) encoder.Indent("", " ") for { token, err := decoder.Token() if err == io.EOF { break } if err != nil { // If it's not valid XML, maybe it's just a fragment, continue or return body break } // Trim whitespace from CharData to normalize if cd, ok := token.(xml.CharData); ok { token = xml.CharData(bytes.TrimSpace(cd)) } err = encoder.EncodeToken(token) if err != nil { break } } encoder.Flush() if out.Len() > 0 { return out.String() } } // Try JSON if strings.HasPrefix(body, "{") || strings.HasPrefix(body, "[") { var obj interface{} if err := json.Unmarshal([]byte(body), &obj); err == nil { // Mask some JSON fields if they are common maskJSON(obj) pretty, _ := json.MarshalIndent(obj, "", " ") return string(pretty) } } return body } func maskJSON(data interface{}) { switch v := data.(type) { case map[string]interface{}: for k, val := range v { if strings.Contains(strings.ToLower(k), "timestamp") || k == "updatedOn" || k == "createdOn" || k == "expires_at" { v[k] = "[MASKED]" } else { maskJSON(val) } } case []interface{}: for _, item := range v { maskJSON(item) } } } func generateHTML(path, file1, file2 string, diffs []diffmatchpatch.Diff) error { var sb strings.Builder sb.WriteString(` HTTP Diff Report

HTTP Response Body Diff

Left: ` + html.EscapeString(file1) + `
Right: ` + html.EscapeString(file2) + `
`) type sideBySideLine struct { leftText string rightText string class string } var lines []sideBySideLine for _, diff := range diffs { text := html.EscapeString(diff.Text) split := strings.Split(text, "\n") // Remove trailing empty string from split if it exists if len(split) > 0 && split[len(split)-1] == "" { split = split[:len(split)-1] } switch diff.Type { case diffmatchpatch.DiffEqual: for _, line := range split { lines = append(lines, sideBySideLine{leftText: line, rightText: line, class: "diff-equal"}) } case diffmatchpatch.DiffInsert: for _, line := range split { lines = append(lines, sideBySideLine{leftText: "", rightText: line, class: "diff-insert"}) } case diffmatchpatch.DiffDelete: for _, line := range split { lines = append(lines, sideBySideLine{leftText: line, rightText: "", class: "diff-delete"}) } } } for i, line := range lines { leftMarker := "" rightMarker := "" if line.class == "diff-insert" { rightMarker = "+" } else if line.class == "diff-delete" { leftMarker = "-" } sb.WriteString(fmt.Sprintf(` `, line.class, i+1, leftMarker, line.leftText, i+1, rightMarker, line.rightText)) } sb.WriteString(`
%d
%s%s
%d
%s%s
`) return os.WriteFile(path, []byte(sb.String()), 0644) }