diff --git a/src/WireMockInspector/ViewModels/JsonDiff.cs b/src/WireMockInspector/ViewModels/JsonDiff.cs new file mode 100644 index 0000000..a4a3504 --- /dev/null +++ b/src/WireMockInspector/ViewModels/JsonDiff.cs @@ -0,0 +1,61 @@ +using System; +using System.Linq; +using Newtonsoft.Json.Linq; + +namespace WireMockInspector.ViewModels; + +public class AlphabeticallySortedJsonDiffFormatter +{ + public static readonly AlphabeticallySortedJsonDiffFormatter Instance = new AlphabeticallySortedJsonDiffFormatter(); + + public string Format(JToken? diff) + { + if (diff == null) + { + return string.Empty; + } + + if (diff is JObject jObject) + { + if(jObject.DeepClone() is JObject sorted) + { + Sort(sorted); + return sorted.ToString(); + } + + return jObject.ToString(); + } + return diff.ToString(); + } + + private static void Sort(JObject jObj) + { + var props = jObj.Properties().ToList(); + foreach (var prop in props) + { + prop.Remove(); + } + + foreach (var prop in props.OrderBy(p => p.Name)) + { + jObj.Add(prop); + if (prop.Value is JObject) + Sort((JObject)prop.Value); + } + } +} + +public static class JsonHelper +{ + public static string ToComparableForm(string json) + { + try + { + return AlphabeticallySortedJsonDiffFormatter.Instance.Format(JToken.Parse(json)); + } + catch (Exception e) + { + return json; + } + } +} \ No newline at end of file diff --git a/src/WireMockInspector/ViewModels/MainWindowViewModel.cs b/src/WireMockInspector/ViewModels/MainWindowViewModel.cs index 9e11a29..cfe4776 100644 --- a/src/WireMockInspector/ViewModels/MainWindowViewModel.cs +++ b/src/WireMockInspector/ViewModels/MainWindowViewModel.cs @@ -7,7 +7,11 @@ using System.Text; using System.Threading.Tasks; using System.Xml; +using DiffPlex; +using DiffPlex.DiffBuilder; +using DiffPlex.DiffBuilder.Model; using DynamicData; +using JsonDiffPatchDotNet; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using ReactiveUI; @@ -19,6 +23,7 @@ using WireMock.Admin.Settings; using WireMock.Client; using WireMock.Types; +using ChangeType = DiffPlex.DiffBuilder.Model.ChangeType; using Formatting = Newtonsoft.Json.Formatting; namespace WireMockInspector.ViewModels @@ -223,7 +228,7 @@ public MainWindowViewModel() { LastHit = hitCalculator.GetFirstPerfectHitDateAfter(m.Guid, estimatedScenarioStateDate ?? DateTime.MinValue), Description = $"[{m.WhenStateIs}] -> [{m.SetStateTo}]", - MappingDefinition = AsMarkdownCode("json", JsonConvert.SerializeObject(m, Formatting.Indented)), + MappingDefinition = new MarkdownCode("json", JsonConvert.SerializeObject(m, Formatting.Indented)), TriggeredBy = new RequestLogViewModel() { MapToLogEntries(hitCalculator.GetPerfectHitCountAfter(m.Guid, estimatedScenarioStateDate)) @@ -272,7 +277,7 @@ public MainWindowViewModel() ExpectedPaths = GetExpectedPathsDescription(model), Description = model.Title != model.Description? model.Description: null, UpdatedOn = model.UpdatedAt, - Content = AsMarkdownCode("json", JsonConvert.SerializeObject(model, Formatting.Indented)), + Content = new MarkdownCode("json", JsonConvert.SerializeObject(model, Formatting.Indented)), PartialHitCount = partialHitCount, PerfectHitCount = perfectHitCount, PerfectMatches = new RequestLogViewModel() @@ -438,7 +443,7 @@ public MainWindowViewModel() .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(code => { - SelectedMapping.Code = AsMarkdownCode("cs", code); + SelectedMapping.Code = new MarkdownCode("cs", code); }); this.WhenAnyValue(x => x.SelectedRequest) @@ -483,8 +488,8 @@ private static List MapToLogEntries(IEnumerable Timestamp = l.Request.DateTime, Method = l.Request.Method, Path = l.Request.Path, - RequestDefinition = AsMarkdownCode("json", JsonConvert.SerializeObject(l.Request, Formatting.Indented)), - ResponseDefinition = AsMarkdownCode("json", JsonConvert.SerializeObject(l.Response, Formatting.Indented)), + RequestDefinition = new MarkdownCode("json", JsonConvert.SerializeObject(l.Request, Formatting.Indented)), + ResponseDefinition = new MarkdownCode("json", JsonConvert.SerializeObject(l.Response, Formatting.Indented)), StatusCode = l.Response.StatusCode?.ToString() } ) .ToList(); @@ -814,24 +819,7 @@ private static MappingDetails GetMappingDetails(RequestViewModel req, MappingMod }, NoExpectations = expectations.Request?.Params is null }, - new() - { - RuleName = "Body", - Matched = IsMatched(req, "BodyMatcher"), - ActualValue = new MarkdownActualValue - { - Value = req.Raw.Request.DetectedBodyType switch - { - "String" or "FormUrlEncoded" => WrapBodyInMarkdown(req.Raw.Request.Body?? string.Empty), - "Json" => AsMarkdownCode("json", req.Raw.Request.BodyAsJson?.ToString() ?? string.Empty), - "Bytes" => new MarkdownCode("plaintext", req.Raw.Request.BodyAsBytes?.ToString()?? string.Empty), - "File" => new MarkdownCode("plaintext","[FileContent]"), - _ => new MarkdownCode("plaintext", "") - } - }, - Expectations = MapToRichExpectations(expectations.Request?.Body), - NoExpectations = expectations.Request?.Body is null - } + MapToRequestBodyViewModel(req, expectations) }, ResponseParts = new MatchDetailsList { @@ -894,6 +882,146 @@ private static MappingDetails GetMappingDetails(RequestViewModel req, MappingMod } }; } + + private static (string, string) ProcessDiff(JToken left, JToken right, JToken diff) + { + var leftViewBuilder = new StringBuilder(); + var rightViewBuilder = new StringBuilder(); + + foreach (var prop in diff.Children()) + { + string key = prop.Name; + var value = prop.Value; + + if (value is JArray {Count: 2}) + { + // Modification + leftViewBuilder.AppendLine($"m: {key}: {left[key]}"); + rightViewBuilder.AppendLine($"m: {key}: {right[key]}"); + } + else if (value is JArray {Count: 3}) + { + // No Changes + leftViewBuilder.AppendLine($"{key}: {left[key]}"); + rightViewBuilder.AppendLine($"{key}: {right[key]}"); + } + else if (value is JArray {Count: 1}) + { + // Addition or Deletion + if (left[key] == null) + { + // Addition + leftViewBuilder.AppendLine($""); + rightViewBuilder.AppendLine($"{key}: {right[key]}"); + } + else + { + // Deletion + leftViewBuilder.AppendLine($"{key}: {left[key]}"); + rightViewBuilder.AppendLine($""); + } + } + } + + return (leftViewBuilder.ToString(), rightViewBuilder.ToString()); + } + + private static MatchDetailsViewModel MapToRequestBodyViewModel(RequestViewModel req, MappingModel expectations) + { + var actualValue = req.Raw.Request.DetectedBodyType switch + { + "String" or "FormUrlEncoded" => WrapBodyInMarkdown(req.Raw.Request.Body?? string.Empty), + "Json" => new MarkdownCode("json", req.Raw.Request.BodyAsJson?.ToString() ?? string.Empty), + "Bytes" => new MarkdownCode("plaintext", req.Raw.Request.BodyAsBytes?.ToString()?? string.Empty), + "File" => new MarkdownCode("plaintext","[FileContent]"), + _ => new MarkdownCode("plaintext", "") + }; + + var expectationsModel = MapToRichExpectations(expectations.Request?.Body); + CodeDiffViewModel diffModel = null; + if (actualValue is {lang: "json"} && expectationsModel is RichExpectations {Matchers.Count: 1} re) + { + + if (re.Matchers[0].Patterns[0] is MarkdownCode {lang: "json", rawValue: var expectedValue}) + { + + + var b = new SideBySideDiffBuilder(new Differ()); + var ignoreCase = re.Matchers[0].Tags.Contains("Ignore case"); + var diff = b.BuildDiffModel + ( + oldText: JsonHelper.ToComparableForm(expectedValue), + newText: JsonHelper.ToComparableForm(actualValue.rawValue), + ignoreWhitespace: false, + ignoreCase: ignoreCase + ); + + foreach (var (a1, a2) in diff.OldText.Lines.Zip(diff.NewText.Lines)) + { + if (a1.Type == ChangeType.Modified && a2.Type == ChangeType.Modified) + { + // INFO: When new line was added, we don't want to report previous line as change (',' is added at the end of line in json) + if (string.Equals(a1.Text.TrimEnd(','), a2.Text.TrimEnd(','), ignoreCase? StringComparison.OrdinalIgnoreCase: StringComparison.Ordinal)) + { + a1.Type = ChangeType.Unchanged; + a2.Type = ChangeType.Unchanged; + } + + } + } + + diffModel = new CodeDiffViewModel + { + Left = new MarkdownCode("json", PresentDiffLinexs(diff.OldText.Lines), diff.OldText.Lines), + Right = new MarkdownCode("json", PresentDiffLinexs(diff.NewText.Lines),diff.NewText.Lines) + }; + } + } + + return new() + { + RuleName = "Body", + Matched = IsMatched(req, "BodyMatcher"), + ActualValue = new MarkdownActualValue + { + Value = actualValue + }, + Expectations = expectationsModel, + NoExpectations = expectations.Request?.Body is null, + Diff = diffModel + }; + } + + private static string PresentDiffLinexs(List lines) + { + var leftBuilder = new StringBuilder(); + foreach (var oldLine in lines) + { + switch (oldLine.Type) + { + case ChangeType.Unchanged: + leftBuilder.AppendLine(oldLine.Text); + break; + case ChangeType.Deleted: + leftBuilder.AppendLine(oldLine.Text); + break; + case ChangeType.Inserted: + leftBuilder.AppendLine(oldLine.Text); + break; + case ChangeType.Imaginary: + leftBuilder.AppendLine(oldLine.Text); + break; + case ChangeType.Modified: + leftBuilder.AppendLine(oldLine.Text); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var l = leftBuilder.ToString(); + return l; + } private static string FormatStatusCode(object? code) { @@ -973,7 +1101,7 @@ IEnumerable GetTags(MatcherModel m) return new RichExpectations { - Definition = AsMarkdownCode("json", JsonConvert.SerializeObject(definition, Formatting.Indented)), + Definition = new MarkdownCode("json", JsonConvert.SerializeObject(definition, Formatting.Indented)), Operator = matchOperator, Matchers = matchers.Select(x => new ExpectationMatcher() { @@ -988,13 +1116,18 @@ IEnumerable GetTags(MatcherModel m) Tags = GetTags(x).ToList(), Patterns = GetPatterns(x).Select(y => y.Trim() switch { - var v when v.StartsWith("{") || v.StartsWith("[") => (Text) new MarkdownCode("json", y).TryToReformat(), + var v when IsJsonString(v) => (Text) new MarkdownCode("json", y).TryToReformat(), _ => (Text)new SimpleText(y) } ).ToList() }).ToList() }; } + private static bool IsJsonString(string v) + { + return v.StartsWith("{") || v.StartsWith("["); + } + private static MarkdownCode GetActualForRequestBody(RequestViewModel req) { return req.Raw.Response?.DetectedBodyType.ToString() switch @@ -1009,7 +1142,7 @@ private static MarkdownCode GetActualForRequestBody(RequestViewModel req) private static MarkdownCode WrapBodyInMarkdown(string bodyResponse) { var cleanBody = bodyResponse.Trim(); - if (cleanBody.StartsWith("[") || cleanBody.StartsWith("{")) + if (IsJsonString(cleanBody)) { return new MarkdownCode("json", bodyResponse); @@ -1022,7 +1155,6 @@ private static MarkdownCode WrapBodyInMarkdown(string bodyResponse) return new MarkdownCode("plaintext", bodyResponse); } - public static MarkdownCode AsMarkdownCode(string lang, string code) => new MarkdownCode(lang, code); public string RequestSearchTerm { @@ -1043,7 +1175,7 @@ private static ExpectationsModel ExpectationsAsJson(object? data) return new RawExpectations() { - Definition = AsMarkdownCode("json", JsonConvert.SerializeObject(data, Formatting.Indented)) + Definition = new MarkdownCode("json", JsonConvert.SerializeObject(data, Formatting.Indented)) }; } diff --git a/src/WireMockInspector/ViewModels/MatchDetailsViewModel.cs b/src/WireMockInspector/ViewModels/MatchDetailsViewModel.cs index e256250..0a49661 100644 --- a/src/WireMockInspector/ViewModels/MatchDetailsViewModel.cs +++ b/src/WireMockInspector/ViewModels/MatchDetailsViewModel.cs @@ -5,6 +5,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; +using DiffPlex.DiffBuilder.Model; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using ReactiveUI; @@ -61,7 +62,15 @@ public class RichExpectations:ExpectationsModel public MarkdownCode Definition { get; set; } public string? Operator { get; set; } public List Matchers { get; set; } -} +} + + +public class CodeDiffViewModel +{ + public MarkdownCode Left { get; set; } + public MarkdownCode Right { get; set; } + +} public class MatchDetailsViewModel:ViewModelBase { @@ -86,6 +95,15 @@ public ExpectationsModel Expectations private ExpectationsModel _expectations; + public CodeDiffViewModel Diff + { + get => _diff; + set => this.RaiseAndSetIfChanged(ref _diff, value); + } + + private CodeDiffViewModel _diff; + + public ICommand ReformatActualValue { get; set; } @@ -157,7 +175,7 @@ public class MarkdownActualValue:ActualValue public record Text(); public record SimpleText(string Value):Text; -public record MarkdownCode(string lang, string rawValue):Text +public record MarkdownCode(string lang, string rawValue, List? oldTextLines = null):Text { public string AsMarkdownSyntax() { @@ -180,7 +198,7 @@ public MarkdownCode TryToReformat() { var formatted = JToken.Parse(this.rawValue).ToString(Formatting.Indented); - return MainWindowViewModel.AsMarkdownCode("json", formatted); + return new MarkdownCode("json", formatted); } catch (Exception e) { diff --git a/src/WireMockInspector/Views/CodeBlockViewer.cs b/src/WireMockInspector/Views/CodeBlockViewer.cs index 4abd6e7..7c4c976 100644 --- a/src/WireMockInspector/Views/CodeBlockViewer.cs +++ b/src/WireMockInspector/Views/CodeBlockViewer.cs @@ -1,9 +1,13 @@ using System; +using System.Collections.Generic; +using System.Linq; using Avalonia; using Avalonia.Media; using AvaloniaEdit; using AvaloniaEdit.Document; +using AvaloniaEdit.Rendering; using AvaloniaEdit.TextMate; +using DiffPlex.DiffBuilder.Model; using TextMateSharp.Grammars; using WireMockInspector.ViewModels; @@ -65,7 +69,16 @@ private void SetMarkdown(ViewModels.MarkdownCode md) if (_registryOptions.GetLanguageByExtension("." + md.lang) is { } languageByExtension) { _textMateInstallation.SetGrammar(_registryOptions.GetScopeByLanguageId(languageByExtension.Id)); - } + + + } + + + if (this.TextArea.TextView.LineTransformers.OfType().FirstOrDefault() is { } existing) + { + this.TextArea.TextView.LineTransformers.Remove(existing); + } + this.TextArea.TextView.LineTransformers.Add(new DiffLineBackgroundRenderer(md.oldTextLines)); } } else @@ -85,4 +98,44 @@ public ViewModels.MarkdownCode Code public static readonly StyledProperty CodeProperty = AvaloniaProperty.Register(nameof(Code)); private readonly TextMate.Installation _textMateInstallation; private readonly RegistryOptions _registryOptions; -} \ No newline at end of file +} + + + +public class DiffLineBackgroundRenderer : GenericLineTransformer +{ + private readonly List? _mdOldTextLines; + + + protected override void TransformLine(DocumentLine line, ITextRunConstructionContext context) + { + if (_mdOldTextLines is { } li) + { + var index = line.LineNumber -1; + if (index is > -1 && index < li.Count) + { + var brush = li[index].Type switch + { + ChangeType.Deleted => new SolidColorBrush(Colors.Red, 0.5), + ChangeType.Inserted => new SolidColorBrush(Colors.Green, 0.5), + ChangeType.Imaginary => new SolidColorBrush(Colors.Gray, 0.5), + ChangeType.Modified => new SolidColorBrush(Colors.Orange, 0.5), + _ => null + }; + if (brush != null) + { + this.SetTextStyle(line,0, line.Length, null, brush, context.GlobalTextRunProperties.Typeface.Style, context.GlobalTextRunProperties.Typeface.Weight, false); + } + } + + } + + + } + + public DiffLineBackgroundRenderer(List? mdOldTextLines) : base((_)=>{}) + { + _mdOldTextLines = mdOldTextLines; + } +} + diff --git a/src/WireMockInspector/Views/RequestDetails.axaml b/src/WireMockInspector/Views/RequestDetails.axaml index 1d6d206..3de95ce 100644 --- a/src/WireMockInspector/Views/RequestDetails.axaml +++ b/src/WireMockInspector/Views/RequestDetails.axaml @@ -253,8 +253,19 @@ - - + + + + + + + + + + + + + diff --git a/src/WireMockInspector/WireMockInspector.csproj b/src/WireMockInspector/WireMockInspector.csproj index df696fa..76cb4e0 100644 --- a/src/WireMockInspector/WireMockInspector.csproj +++ b/src/WireMockInspector/WireMockInspector.csproj @@ -50,6 +50,8 @@ + +