diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..15dfda8c --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,5 @@ +# Copilot Instructions + +## Project Guidelines +- When running tests in this workspace, do not run tests for the net48 target framework. +- When changing System.Text.Json code in this workspace, verify API availability for netstandard2.0 and netstandard2.1 instead of assuming newer APIs exist. \ No newline at end of file diff --git a/src/WireMock.Net.Minimal/Settings/WireMockServerSettingsParser.cs b/src/WireMock.Net.Minimal/Settings/WireMockServerSettingsParser.cs index 7ecbd3a4..1ba76c29 100644 --- a/src/WireMock.Net.Minimal/Settings/WireMockServerSettingsParser.cs +++ b/src/WireMock.Net.Minimal/Settings/WireMockServerSettingsParser.cs @@ -10,6 +10,7 @@ using WireMock.Constants; using WireMock.Logging; using WireMock.Models; using WireMock.Types; +using WireMock.Transformers; using WireMock.Util; namespace WireMock.Settings; @@ -74,6 +75,8 @@ public static class WireMockServerSettingsParser WatchStaticMappingsInSubdirectories = parser.GetBoolValue(nameof(WireMockServerSettings.WatchStaticMappingsInSubdirectories)), }; + settings.DefaultJsonBodyTransformer = new NewtonsoftJsonBodyTransformer(settings); + #if USE_ASPNETCORE settings.CorsPolicyOptions = parser.GetEnumValue(nameof(WireMockServerSettings.CorsPolicyOptions), CorsPolicyOptions.None); settings.ClientCertificateMode = parser.GetEnumValue(nameof(WireMockServerSettings.ClientCertificateMode), ClientCertificateMode.NoCertificate); diff --git a/src/WireMock.Net.Minimal/Transformers/Handlebars/IHandlebarsContext.cs b/src/WireMock.Net.Minimal/Transformers/Handlebars/IHandlebarsContext.cs index 619e11e4..6bb00cdb 100644 --- a/src/WireMock.Net.Minimal/Transformers/Handlebars/IHandlebarsContext.cs +++ b/src/WireMock.Net.Minimal/Transformers/Handlebars/IHandlebarsContext.cs @@ -1,6 +1,7 @@ // Copyright © WireMock.Net using HandlebarsDotNet; +using WireMock.Transformers; namespace WireMock.Transformers.Handlebars; diff --git a/src/WireMock.Net.Minimal/Transformers/ITransformerContext.cs b/src/WireMock.Net.Minimal/Transformers/ITransformerContext.cs deleted file mode 100644 index 8f7eeef6..00000000 --- a/src/WireMock.Net.Minimal/Transformers/ITransformerContext.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright © WireMock.Net - -using WireMock.Handlers; - -namespace WireMock.Transformers; - -internal interface ITransformerContext -{ - IFileSystemHandler FileSystemHandler { get; } - - string ParseAndRender(string text, object model); - - object? ParseAndEvaluate(string text, object model); -} \ No newline at end of file diff --git a/src/WireMock.Net.Minimal/Transformers/Transformer.cs b/src/WireMock.Net.Minimal/Transformers/Transformer.cs index 6fa6b121..276bac1d 100644 --- a/src/WireMock.Net.Minimal/Transformers/Transformer.cs +++ b/src/WireMock.Net.Minimal/Transformers/Transformer.cs @@ -1,10 +1,5 @@ // Copyright © WireMock.Net -using System.Collections; -using System.Linq; -using HandlebarsDotNet.Helpers.Models; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; using Stef.Validation; using WireMock.Settings; using WireMock.Types; @@ -14,17 +9,13 @@ namespace WireMock.Transformers; internal class Transformer : ITransformer { - private readonly JsonSerializer _jsonSerializer; + private readonly IJsonBodyTransformer _jsonBodyTransformer; private readonly ITransformerContextFactory _factory; public Transformer(WireMockServerSettings settings, ITransformerContextFactory factory) { _factory = Guard.NotNull(factory); - - _jsonSerializer = new JsonSerializer - { - Culture = Guard.NotNull(settings).Culture - }; + _jsonBodyTransformer = Guard.NotNull(settings).DefaultJsonBodyTransformer; } public IBodyData? TransformBody( @@ -121,13 +112,17 @@ internal class Transformer : ITransformer }); } - private IBodyData? TransformBodyData(ITransformerContext transformerContext, ReplaceNodeOptions options, TransformModel model, IBodyData original, bool useTransformerForBodyAsFile) + private BodyData? TransformBodyData(ITransformerContext transformerContext, ReplaceNodeOptions options, TransformModel model, IBodyData original, bool useTransformerForBodyAsFile) { switch (original.DetectedBodyType) { case BodyType.Json: case BodyType.ProtoBuf: - return TransformBodyAsJson(transformerContext, options, model, original); + return _jsonBodyTransformer.TransformBodyAsJson( + transformerContext, + options, + model, + original); case BodyType.File: return TransformBodyAsFile(transformerContext, model, original, useTransformerForBodyAsFile); @@ -159,185 +154,7 @@ internal class Transformer : ITransformer return newHeaders; } - private IBodyData TransformBodyAsJson(ITransformerContext transformerContext, ReplaceNodeOptions options, object model, IBodyData original) - { - JToken? jToken = null; - switch (original.BodyAsJson) - { - case JObject bodyAsJObject: - jToken = bodyAsJObject.DeepClone(); - WalkNode(transformerContext, options, jToken, model); - break; - - case JArray bodyAsJArray: - jToken = bodyAsJArray.DeepClone(); - WalkNode(transformerContext, options, jToken, model); - break; - - case var bodyAsEnumerable when bodyAsEnumerable is IEnumerable and not string: - jToken = JArray.FromObject(bodyAsEnumerable, _jsonSerializer); - WalkNode(transformerContext, options, jToken, model); - break; - - case string bodyAsString: - jToken = ReplaceSingleNode(transformerContext, options, bodyAsString, model); - break; - - case not null: - jToken = JObject.FromObject(original.BodyAsJson, _jsonSerializer); - WalkNode(transformerContext, options, jToken, model); - break; - } - - return new BodyData - { - Encoding = original.Encoding, - DetectedBodyType = original.DetectedBodyType, - DetectedBodyTypeFromContentType = original.DetectedBodyTypeFromContentType, - ProtoDefinition = original.ProtoDefinition, - ProtoBufMessageType = original.ProtoBufMessageType, - BodyAsJson = jToken - }; - } - - private JToken ReplaceSingleNode(ITransformerContext transformerContext, ReplaceNodeOptions options, string stringValue, object model) - { - var transformedString = transformerContext.ParseAndRender(stringValue, model); - - if (!string.Equals(stringValue, transformedString)) - { - const string property = "_"; - JObject dummy = JObject.Parse($"{{ \"{property}\": null }}"); - if (dummy[property] == null) - { - // TODO: check if just returning null is fine - return string.Empty; - } - - JToken node = dummy[property]!; - - ReplaceNodeValue(options, node, transformedString); - - return dummy[property]!; - } - - return stringValue; - } - - private void WalkNode(ITransformerContext transformerContext, ReplaceNodeOptions options, JToken node, object model) - { - switch (node.Type) - { - case JTokenType.Object: - // In case of Object, loop all children. Do a ToArray() to avoid `Collection was modified` exceptions. - foreach (var child in node.Children().ToArray()) - { - WalkNode(transformerContext, options, child.Value, model); - } - break; - - case JTokenType.Array: - // In case of Array, loop all items. Do a ToArray() to avoid `Collection was modified` exceptions. - foreach (var child in node.Children().ToArray()) - { - WalkNode(transformerContext, options, child, model); - } - break; - - case JTokenType.String: - // In case of string, try to transform the value. - var stringValue = node.Value(); - if (string.IsNullOrEmpty(stringValue)) - { - return; - } - - var transformed = transformerContext.ParseAndEvaluate(stringValue!, model); - if (!Equals(stringValue, transformed)) - { - ReplaceNodeValue(options, node, transformed); - } - break; - } - } - - // ReSharper disable once UnusedParameter.Local - private void ReplaceNodeValue(ReplaceNodeOptions options, JToken node, object? transformedValue) - { - switch (transformedValue) - { - case JValue jValue: - node.Replace(jValue); - return; - - case string transformedString: - var (isConvertedFromString, convertedValueFromString) = TryConvert(options, transformedString); - if (isConvertedFromString) - { - node.Replace(JToken.FromObject(convertedValueFromString, _jsonSerializer)); - } - else - { - node.Replace(ParseAsJObject(transformedString)); - } - break; - - case WireMockList strings: - switch (strings.Count) - { - case 1: - node.Replace(ParseAsJObject(strings[0])); - return; - - case > 1: - node.Replace(JToken.FromObject(strings.ToArray(), _jsonSerializer)); - return; - } - break; - - case { }: - var (isConverted, convertedValue) = TryConvert(options, transformedValue); - if (isConverted) - { - node.Replace(JToken.FromObject(convertedValue, _jsonSerializer)); - } - return; - - default: // It's null, skip it. Maybe remove it ? - return; - } - } - - private static JToken ParseAsJObject(string stringValue) - { - return JsonUtils.TryParseAsJObject(stringValue, out var parsedAsjObject) ? parsedAsjObject : stringValue; - } - - private static (bool IsConverted, object ConvertedValue) TryConvert(ReplaceNodeOptions options, object value) - { - var valueAsString = value as string; - - if (options == ReplaceNodeOptions.Evaluate) - { - if (valueAsString != null && WrappedString.TryDecode(valueAsString, out var decoded)) - { - return (true, decoded); - } - - return (false, value); - } - - if (valueAsString != null) - { - return WrappedString.TryDecode(valueAsString, out var decoded) ? - (true, decoded) : - StringUtils.TryConvertToKnownType(valueAsString); - } - - return (false, value); - } - - private static IBodyData TransformBodyAsString(ITransformerContext transformerContext, object model, IBodyData original) + private static BodyData TransformBodyAsString(ITransformerContext transformerContext, object model, IBodyData original) { return new BodyData { @@ -348,7 +165,7 @@ internal class Transformer : ITransformer }; } - private static IBodyData TransformBodyAsFile(ITransformerContext transformerContext, object model, IBodyData original, bool useTransformerForBodyAsFile) + private static BodyData TransformBodyAsFile(ITransformerContext transformerContext, object model, IBodyData original, bool useTransformerForBodyAsFile) { var transformedBodyAsFilename = transformerContext.ParseAndRender(original.BodyAsFile!, model); diff --git a/src/WireMock.Net.Shared/Settings/WireMockServerSettings.cs b/src/WireMock.Net.Shared/Settings/WireMockServerSettings.cs index 1f1b343c..4d91fc34 100644 --- a/src/WireMock.Net.Shared/Settings/WireMockServerSettings.cs +++ b/src/WireMock.Net.Shared/Settings/WireMockServerSettings.cs @@ -14,6 +14,7 @@ using WireMock.Logging; using WireMock.Matchers; using WireMock.Models; using WireMock.RegularExpressions; +using WireMock.Transformers; using WireMock.Types; namespace WireMock.Settings; @@ -362,11 +363,31 @@ public class WireMockServerSettings /// Default is . /// [PublicAPI] - public IJsonConverter DefaultJsonSerializer { get; set; } = new NewtonsoftJsonConverter(); + public IJsonConverter DefaultJsonSerializer { get; set; } + + /// + /// Gets or sets the default JSON body transformer used for template-based JSON body transformations. + /// + /// + /// Set this property to provide a custom implementation for transforming JSON and ProtoBuf body content. + /// Default is . + /// + [PublicAPI] + [JsonIgnore] + public IJsonBodyTransformer DefaultJsonBodyTransformer { get; set; } /// /// WebSocket settings. /// [PublicAPI] public WebSocketSettings? WebSocketSettings { get; set; } + + /// + /// Initializes a new instance of the class. + /// + public WireMockServerSettings() + { + DefaultJsonSerializer = new NewtonsoftJsonConverter(); + DefaultJsonBodyTransformer = new NewtonsoftJsonBodyTransformer(this); + } } \ No newline at end of file diff --git a/src/WireMock.Net.Shared/Transformers/IJsonBodyTransformer.cs b/src/WireMock.Net.Shared/Transformers/IJsonBodyTransformer.cs new file mode 100644 index 00000000..64b4b673 --- /dev/null +++ b/src/WireMock.Net.Shared/Transformers/IJsonBodyTransformer.cs @@ -0,0 +1,28 @@ +// Copyright © WireMock.Net + +using JetBrains.Annotations; +using WireMock.Types; +using WireMock.Util; + +namespace WireMock.Transformers; + +/// +/// Defines the contract for transforming JSON-like body data using a transformer context. +/// +[PublicAPI] +public interface IJsonBodyTransformer +{ + /// + /// Transforms the JSON body using the provided transformer context and model. + /// + /// The transformer context used to render and evaluate template values. + /// The JSON node replacement behavior to apply during transformation. + /// The model used when rendering or evaluating template values. + /// The original body data to transform. + /// The transformed JSON body data. + BodyData TransformBodyAsJson( + ITransformerContext transformerContext, + ReplaceNodeOptions options, + object model, + IBodyData original); +} diff --git a/src/WireMock.Net.Shared/Transformers/ITransformerContext.cs b/src/WireMock.Net.Shared/Transformers/ITransformerContext.cs new file mode 100644 index 00000000..69dfe92e --- /dev/null +++ b/src/WireMock.Net.Shared/Transformers/ITransformerContext.cs @@ -0,0 +1,34 @@ +// Copyright © WireMock.Net + +using JetBrains.Annotations; +using WireMock.Handlers; + +namespace WireMock.Transformers; + +/// +/// Defines the transformer context used to render and evaluate templates during response transformation. +/// +[PublicAPI] +public interface ITransformerContext +{ + /// + /// Gets the file system handler used by the current transformer context. + /// + IFileSystemHandler FileSystemHandler { get; } + + /// + /// Renders the specified template text using the supplied model. + /// + /// The template text to render. + /// The model used during rendering. + /// The rendered text. + string ParseAndRender(string text, object model); + + /// + /// Evaluates the specified template text using the supplied model. + /// + /// The template text to evaluate. + /// The model used during evaluation. + /// The evaluated value. + object? ParseAndEvaluate(string text, object model); +} diff --git a/src/WireMock.Net.Shared/Transformers/NewtonsoftJsonBodyTransformer.cs b/src/WireMock.Net.Shared/Transformers/NewtonsoftJsonBodyTransformer.cs new file mode 100644 index 00000000..bd60c7de --- /dev/null +++ b/src/WireMock.Net.Shared/Transformers/NewtonsoftJsonBodyTransformer.cs @@ -0,0 +1,271 @@ +// Copyright © WireMock.Net + +using System.Collections; +using HandlebarsDotNet.Helpers.Models; +using JetBrains.Annotations; +using JsonConverter.Abstractions; +using JsonConverter.Newtonsoft.Json; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using WireMock.Settings; +using WireMock.Types; +using WireMock.Util; + +namespace WireMock.Transformers; + +/// +/// Default JSON body transformer implementation based on Newtonsoft.Json. +/// +/// +/// Initializes a new instance of the class. +/// +/// The server settings used to configure JSON transformation behavior. +[PublicAPI] +public class NewtonsoftJsonBodyTransformer(WireMockServerSettings settings) : IJsonBodyTransformer +{ + private readonly IJsonConverter _jsonConverter = new NewtonsoftJsonConverter(); + + /// + public BodyData TransformBodyAsJson( + ITransformerContext transformerContext, + ReplaceNodeOptions options, + object model, + IBodyData original) + { + var jsonSerializer = new JsonSerializer + { + Culture = settings.Culture + }; + + JToken? jToken = null; + switch (original.BodyAsJson) + { + case JObject bodyAsJObject: + jToken = bodyAsJObject.DeepClone(); + WalkNode(transformerContext, jsonSerializer, options, jToken, model); + break; + + case JArray bodyAsJArray: + jToken = bodyAsJArray.DeepClone(); + WalkNode(transformerContext, jsonSerializer, options, jToken, model); + break; + + case var bodyAsEnumerable when bodyAsEnumerable is IEnumerable and not string: + jToken = JArray.FromObject(bodyAsEnumerable, jsonSerializer); + WalkNode(transformerContext, jsonSerializer, options, jToken, model); + break; + + case string bodyAsString: + jToken = ReplaceSingleNode(transformerContext, jsonSerializer, options, bodyAsString, model); + break; + + case not null: + jToken = JObject.FromObject(original.BodyAsJson, jsonSerializer); + WalkNode(transformerContext, jsonSerializer, options, jToken, model); + break; + } + + return new BodyData + { + Encoding = original.Encoding, + DetectedBodyType = original.DetectedBodyType, + DetectedBodyTypeFromContentType = original.DetectedBodyTypeFromContentType, + ProtoDefinition = original.ProtoDefinition, + ProtoBufMessageType = original.ProtoBufMessageType, + BodyAsJson = jToken + }; + } + + private JToken ParseAsJObject(string stringValue) + { + if (_jsonConverter.IsValidJson(stringValue)) + { + try + { + // Try to convert this string into a JObject + return JObject.Parse(stringValue!); + } + catch + { + } + } + + return stringValue; + } + + private JToken ReplaceSingleNode(ITransformerContext transformerContext, JsonSerializer jsonSerializer, ReplaceNodeOptions options, string stringValue, object model) + { + var transformedString = transformerContext.ParseAndRender(stringValue, model); + + if (!string.Equals(stringValue, transformedString)) + { + const string property = "_"; + JObject dummy = JObject.Parse($"{{ \"{property}\": null }}"); + if (dummy[property] == null) + { + return string.Empty; + } + + JToken node = dummy[property]!; + + ReplaceNodeValue(jsonSerializer, options, node, transformedString); + + return dummy[property]!; + } + + return stringValue; + } + + private void WalkNode(ITransformerContext transformerContext, JsonSerializer jsonSerializer, ReplaceNodeOptions options, JToken node, object model) + { + switch (node.Type) + { + case JTokenType.Object: + foreach (var child in node.Children().ToArray()) + { + WalkNode(transformerContext, jsonSerializer, options, child.Value, model); + } + break; + + case JTokenType.Array: + foreach (var child in node.Children().ToArray()) + { + WalkNode(transformerContext, jsonSerializer, options, child, model); + } + break; + + case JTokenType.String: + var stringValue = node.Value(); + if (string.IsNullOrEmpty(stringValue)) + { + return; + } + + var transformed = transformerContext.ParseAndEvaluate(stringValue!, model); + if (!Equals(stringValue, transformed)) + { + ReplaceNodeValue(jsonSerializer, options, node, transformed); + } + break; + } + } + + private void ReplaceNodeValue(JsonSerializer jsonSerializer, ReplaceNodeOptions options, JToken node, object? transformedValue) + { + switch (transformedValue) + { + case JValue jValue: + node.Replace(jValue); + return; + + case string transformedString: + var (isConvertedFromString, convertedValueFromString) = TryConvert(options, transformedString); + if (isConvertedFromString) + { + node.Replace(JToken.FromObject(convertedValueFromString, jsonSerializer)); + } + else + { + node.Replace(ParseAsJObject(transformedString)); + } + break; + + case WireMockList strings: + switch (strings.Count) + { + case 1: + node.Replace(ParseAsJObject(strings[0])); + return; + + case > 1: + node.Replace(JToken.FromObject(strings.ToArray(), jsonSerializer)); + return; + } + break; + + case { }: + var (isConverted, convertedValue) = TryConvert(options, transformedValue); + if (isConverted) + { + node.Replace(JToken.FromObject(convertedValue, jsonSerializer)); + } + return; + + default: + return; + } + } + + private static (bool IsConverted, object ConvertedValue) TryConvert(ReplaceNodeOptions options, object value) + { + var valueAsString = value as string; + + if (options == ReplaceNodeOptions.Evaluate) + { + if (valueAsString != null && WrappedString.TryDecode(valueAsString, out var decoded)) + { + return (true, decoded); + } + + return (false, value); + } + + if (valueAsString != null) + { + return WrappedString.TryDecode(valueAsString, out var decoded) + ? (true, decoded) + : TryConvertToKnownType(valueAsString); + } + + return (false, value); + } + + internal static (bool IsConverted, object ConvertedValue) TryConvertToKnownType(string value) + { + if (bool.TryParse(value, out var boolResult)) + { + return (true, boolResult); + } + + if (int.TryParse(value, out var intResult)) + { + return (true, intResult); + } + + if (long.TryParse(value, out var longResult)) + { + return (true, longResult); + } + + if (double.TryParse(value, out var doubleResult)) + { + return (true, doubleResult); + } + + if (Guid.TryParseExact(value, "D", out var guidResult)) + { + return (true, guidResult); + } + + if (TimeSpan.TryParse(value, out var timeSpanResult)) + { + return (true, timeSpanResult); + } + + if (DateTime.TryParse(value, out var dateTimeResult)) + { + return (true, dateTimeResult); + } + + if ((value.StartsWith("ftp://", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("ftps://", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) && + Uri.TryCreate(value, UriKind.RelativeOrAbsolute, out var uriResult)) + { + return (true, uriResult); + } + + return (false, value); + } +} diff --git a/src/WireMock.Net.Shared/Transformers/SystemTextJsonBodyTransformer.cs b/src/WireMock.Net.Shared/Transformers/SystemTextJsonBodyTransformer.cs new file mode 100644 index 00000000..89d46228 --- /dev/null +++ b/src/WireMock.Net.Shared/Transformers/SystemTextJsonBodyTransformer.cs @@ -0,0 +1,210 @@ +// Copyright © WireMock.Net + +using System.Collections; +using System.Text.Json; +using System.Text.Json.Nodes; +using HandlebarsDotNet.Helpers.Models; +using JetBrains.Annotations; +using JsonConverter.Abstractions; +using JsonConverter.System.Text.Json; +using WireMock.Settings; +using WireMock.Types; +using WireMock.Util; + +namespace WireMock.Transformers; + +/// +/// JSON body transformer implementation based on System.Text.Json. +/// +/// The server settings used to configure JSON transformation behavior. +[PublicAPI] +public class SystemTextJsonBodyTransformer(WireMockServerSettings settings) : IJsonBodyTransformer +{ + private readonly IJsonConverter _jsonConverter = new SystemTextJsonConverter(); + + /// + public BodyData TransformBodyAsJson( + ITransformerContext transformerContext, + ReplaceNodeOptions options, + object model, + IBodyData original) + { + JsonNode? jsonNode = null; + switch (original.BodyAsJson) + { + case JsonObject bodyAsJsonObject: + jsonNode = CloneNode(bodyAsJsonObject); + jsonNode = WalkNode(transformerContext, options, jsonNode, model); + break; + + case JsonArray bodyAsJsonArray: + jsonNode = CloneNode(bodyAsJsonArray); + jsonNode = WalkNode(transformerContext, options, jsonNode, model); + break; + + case var bodyAsEnumerable when bodyAsEnumerable is IEnumerable and not string: + jsonNode = JsonSerializer.SerializeToNode(bodyAsEnumerable); + if (jsonNode != null) + { + jsonNode = WalkNode(transformerContext, options, jsonNode, model); + } + break; + + case string bodyAsString: + jsonNode = ReplaceSingleNode(transformerContext, options, bodyAsString, model); + break; + + case not null: + jsonNode = JsonSerializer.SerializeToNode(original.BodyAsJson); + if (jsonNode != null) + { + jsonNode = WalkNode(transformerContext, options, jsonNode, model); + } + break; + } + + return new BodyData + { + Encoding = original.Encoding, + DetectedBodyType = original.DetectedBodyType, + DetectedBodyTypeFromContentType = original.DetectedBodyTypeFromContentType, + ProtoDefinition = original.ProtoDefinition, + ProtoBufMessageType = original.ProtoBufMessageType, + BodyAsJson = jsonNode + }; + } + + private JsonNode ParseAsJsonObject(string stringValue) + { + if (_jsonConverter.IsValidJson(stringValue)) + { + try + { + var parsed = JsonNode.Parse(stringValue); + if (parsed is JsonObject) + { + return parsed; + } + } + catch + { + // Ignore and return as string. + } + } + + return JsonValue.Create(stringValue)!; + } + + private JsonNode? ReplaceSingleNode(ITransformerContext transformerContext, ReplaceNodeOptions options, string stringValue, object model) + { + var transformedString = transformerContext.ParseAndRender(stringValue, model); + + if (!string.Equals(stringValue, transformedString)) + { + return ReplaceNodeValue(options, transformedString); + } + + return JsonValue.Create(stringValue); + } + + private JsonNode? WalkNode(ITransformerContext transformerContext, ReplaceNodeOptions options, JsonNode? node, object model) + { + switch (node) + { + case JsonObject jsonObject: + foreach (var property in jsonObject.ToArray()) + { + jsonObject[property.Key] = WalkNode(transformerContext, options, property.Value, model); + } + return jsonObject; + + case JsonArray jsonArray: + for (var i = 0; i < jsonArray.Count; i++) + { + jsonArray[i] = WalkNode(transformerContext, options, jsonArray[i], model); + } + return jsonArray; + + case JsonValue jsonValue when jsonValue.TryGetValue(out var stringValue): + if (string.IsNullOrEmpty(stringValue)) + { + return jsonValue; + } + + var transformed = transformerContext.ParseAndEvaluate(stringValue!, model); + return !Equals(stringValue, transformed) ? ReplaceNodeValue(options, transformed) ?? jsonValue : jsonValue; + + default: + return node; + } + } + + private JsonNode? ReplaceNodeValue(ReplaceNodeOptions options, object? transformedValue) + { + switch (transformedValue) + { + case JsonNode jsonNode: + return CloneNode(jsonNode); + + case string transformedString: + var (isConvertedFromString, convertedValueFromString) = TryConvert(options, transformedString); + return isConvertedFromString + ? JsonSerializer.SerializeToNode(convertedValueFromString) + : ParseAsJsonObject(transformedString); + + case WireMockList strings: + switch (strings.Count) + { + case 1: + return ParseAsJsonObject(strings[0]); + + case > 1: + return JsonSerializer.SerializeToNode(strings.ToArray()); + } + break; + + case { }: + var (isConverted, convertedValue) = TryConvert(options, transformedValue); + if (isConverted) + { + return JsonSerializer.SerializeToNode(convertedValue); + } + break; + } + + return null; + } + + private static JsonNode? CloneNode(JsonNode? node) + { +#if NET8_0_OR_GREATER + return node?.DeepClone(); +#else + return node == null ? null : JsonNode.Parse(node.ToJsonString()); +#endif + } + + private static (bool IsConverted, object ConvertedValue) TryConvert(ReplaceNodeOptions options, object value) + { + var valueAsString = value as string; + + if (options == ReplaceNodeOptions.Evaluate) + { + if (valueAsString != null && WrappedString.TryDecode(valueAsString, out var decoded)) + { + return (true, decoded); + } + + return (false, value); + } + + if (valueAsString != null) + { + return WrappedString.TryDecode(valueAsString, out var decoded) + ? (true, decoded) + : NewtonsoftJsonBodyTransformer.TryConvertToKnownType(valueAsString); + } + + return (false, value); + } +} \ No newline at end of file diff --git a/src/WireMock.Net.Shared/WireMock.Net.Shared.csproj b/src/WireMock.Net.Shared/WireMock.Net.Shared.csproj index def7cf73..981b915d 100644 --- a/src/WireMock.Net.Shared/WireMock.Net.Shared.csproj +++ b/src/WireMock.Net.Shared/WireMock.Net.Shared.csproj @@ -31,6 +31,7 @@ + diff --git a/test/WireMock.Net.Tests/Transformers/JsonBodyTransformerTests.cs b/test/WireMock.Net.Tests/Transformers/JsonBodyTransformerTests.cs new file mode 100644 index 00000000..7bbca7a0 --- /dev/null +++ b/test/WireMock.Net.Tests/Transformers/JsonBodyTransformerTests.cs @@ -0,0 +1,176 @@ +// Copyright © WireMock.Net + +using System.Text; +using System.Text.Json.Nodes; +using Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using WireMock.Handlers; +using WireMock.Settings; +using WireMock.Transformers; +using WireMock.Types; +using WireMock.Util; + +namespace WireMock.Net.Tests.Transformers; + +public class JsonBodyTransformerTests +{ + public static TheoryData Transformers + { + get + { + return new TheoryData + { + new JsonBodyTransformerTestContext( + () => new NewtonsoftJsonBodyTransformer(new WireMockServerSettings()), + JObject.Parse, + body => ((JToken)body).ToString(Formatting.None)), + + new JsonBodyTransformerTestContext( + () => new SystemTextJsonBodyTransformer(new WireMockServerSettings()), + json => JsonNode.Parse(json)!, + body => ((JsonNode)body).ToJsonString()) + }; + } + } + + [Theory] + [MemberData(nameof(Transformers))] + public void TransformBodyAsJson_Replaces_String_Value_And_Preserves_Original(JsonBodyTransformerTestContext testContext) + { + // Arrange + var transformer = testContext.CreateTransformer(); + var originalJson = testContext.ParseJson("{\"value\":\"{{number}}\"}"); + var bodyData = new BodyData + { + Encoding = Encoding.UTF8, + DetectedBodyType = BodyType.Json, + DetectedBodyTypeFromContentType = BodyType.Json, + ProtoBufMessageType = "My.Message", + BodyAsJson = originalJson + }; + + var transformerContext = new FakeTransformerContext( + text => text, + text => text == "{{number}}" ? "123" : text); + + // Act + var result = transformer.TransformBodyAsJson(transformerContext, ReplaceNodeOptions.EvaluateAndTryToConvert, new { }, bodyData); + + // Assert + result.Encoding.Should().Be(Encoding.UTF8); + result.DetectedBodyType.Should().Be(BodyType.Json); + result.DetectedBodyTypeFromContentType.Should().Be(BodyType.Json); + result.ProtoBufMessageType.Should().Be("My.Message"); + result.BodyAsJson.Should().NotBeNull(); + testContext.SerializeJson(result.BodyAsJson).Should().Be("{\"value\":123}"); + testContext.SerializeJson(originalJson).Should().Be("{\"value\":\"{{number}}\"}"); + } + + [Theory] + [MemberData(nameof(Transformers))] + public void TransformBodyAsJson_With_String_Body_Replaces_Single_Node_With_Object(JsonBodyTransformerTestContext testContext) + { + // Arrange + var transformer = testContext.CreateTransformer(); + var bodyData = new BodyData + { + DetectedBodyType = BodyType.Json, + BodyAsJson = "{{json}}" + }; + + var transformerContext = new FakeTransformerContext( + text => text == "{{json}}" ? "{\"name\":\"test\"}" : text, + text => text); + + // Act + var result = transformer.TransformBodyAsJson(transformerContext, ReplaceNodeOptions.EvaluateAndTryToConvert, new { }, bodyData); + + // Assert + result.BodyAsJson.Should().NotBeNull(); + testContext.SerializeJson(result.BodyAsJson).Should().Be("{\"name\":\"test\"}"); + } + + [Theory] + [MemberData(nameof(Transformers))] + public void TransformBodyAsJson_Replaces_String_Value_With_WireMockList_As_Array(JsonBodyTransformerTestContext testContext) + { + // Arrange + var transformer = testContext.CreateTransformer(); + var bodyData = new BodyData + { + DetectedBodyType = BodyType.Json, + BodyAsJson = testContext.ParseJson("{\"values\":\"{{list}}\"}") + }; + + var transformerContext = new FakeTransformerContext( + text => text, + text => text == "{{list}}" ? new WireMockList(new[] { "a", "b" }) : text); + + // Act + var result = transformer.TransformBodyAsJson(transformerContext, ReplaceNodeOptions.EvaluateAndTryToConvert, new { }, bodyData); + + // Assert + result.BodyAsJson.Should().NotBeNull(); + testContext.SerializeJson(result.BodyAsJson).Should().Be("{\"values\":[\"a\",\"b\"]}"); + } + + public sealed class JsonBodyTransformerTestContext + { + private readonly Func _createTransformer; + private readonly Func _parseJson; + private readonly Func _serializeJson; + + public JsonBodyTransformerTestContext( + Func createTransformer, + Func parseJson, + Func serializeJson) + { + _createTransformer = createTransformer; + _parseJson = parseJson; + _serializeJson = serializeJson; + } + + public IJsonBodyTransformer CreateTransformer() + { + return _createTransformer(); + } + + public object ParseJson(string json) + { + return _parseJson(json); + } + + public string SerializeJson(object body) + { + return _serializeJson(body); + } + } + + private sealed class FakeTransformerContext : ITransformerContext + { + private readonly Func _parseAndRender; + private readonly Func _parseAndEvaluate; + + public FakeTransformerContext( + Func parseAndRender, + Func parseAndEvaluate) + { + _parseAndRender = parseAndRender; + _parseAndEvaluate = parseAndEvaluate; + FileSystemHandler = Mock.Of(); + } + + public IFileSystemHandler FileSystemHandler { get; private set; } + + public string ParseAndRender(string text, object model) + { + return _parseAndRender(text); + } + + public object ParseAndEvaluate(string text, object model) + { + return _parseAndEvaluate(text); + } + } +} \ No newline at end of file diff --git a/test/WireMock.Net.Tests/WireMock.Net.Tests.csproj b/test/WireMock.Net.Tests/WireMock.Net.Tests.csproj index 360bd85c..10dc7f75 100644 --- a/test/WireMock.Net.Tests/WireMock.Net.Tests.csproj +++ b/test/WireMock.Net.Tests/WireMock.Net.Tests.csproj @@ -76,7 +76,6 @@ -