From 78b9d7f1c3549415dd421da290692a9e40372730 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Wed, 10 Sep 2025 10:30:43 +0200 Subject: [PATCH 1/3] Adds support for mirroring mock responses. Closes #1384 --- .../Mocking/MockResponsePlugin.cs | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/DevProxy.Plugins/Mocking/MockResponsePlugin.cs b/DevProxy.Plugins/Mocking/MockResponsePlugin.cs index 72ad2c37..d71084c6 100644 --- a/DevProxy.Plugins/Mocking/MockResponsePlugin.cs +++ b/DevProxy.Plugins/Mocking/MockResponsePlugin.cs @@ -374,6 +374,8 @@ private void ProcessMockResponseInternal(ProxyRequestArgs e, MockResponse matchi ProxyUtils.MergeHeaders(headers, rateLimitingHeaders); } + ReplacePlaceholders(matchingResponse.Response, e.Session.HttpClient.Request, Logger); + if (matchingResponse.Response?.Body is not null) { var bodyString = JsonSerializer.Serialize(matchingResponse.Response.Body, ProxyUtils.JsonSerializerOptions) as string; @@ -514,4 +516,192 @@ private static bool HasMatchingBody(MockResponse mockResponse, Request request) return request.BodyString.Contains(mockResponse.Request.BodyFragment, StringComparison.OrdinalIgnoreCase); } + + private static void ReplacePlaceholders(MockResponseResponse? response, Request request, ILogger logger) + { + logger.LogTrace("{Method} called", nameof(ReplacePlaceholders)); + + if (response is null || + response.Body is null || request.BodyString is null) + { + logger.LogTrace("Body is empty. Skipping replacing placeholders"); + return; + } + + try + { + var requestBody = JsonSerializer.Deserialize(request.BodyString, ProxyUtils.JsonSerializerOptions); + + response.Body = ReplacePlaceholdersInObject(response.Body, requestBody, logger); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to parse request body as JSON"); + logger.LogWarning("Failed to parse request body as JSON. Placeholders in the mock response won't be replaced."); + } + + logger.LogTrace("Left {Method}", nameof(ReplacePlaceholders)); + } + + private static object? ReplacePlaceholdersInObject(object? obj, dynamic requestBody, ILogger logger) + { + logger.LogTrace("{Method} called", nameof(ReplacePlaceholdersInObject)); + + if (obj is null) + { + return null; + } + + // Handle JsonElement (which is what we get from System.Text.Json) + if (obj is JsonElement element) + { + return ReplacePlaceholdersInJsonElement(element, requestBody, logger); + } + + // Handle string values - check for placeholders + if (obj is string strValue) + { + return ReplacePlaceholderInString(strValue, requestBody, logger); + } + + // For other types, convert to JsonElement and process + var json = JsonSerializer.Serialize(obj); + var jsonElement = JsonSerializer.Deserialize(json); + return ReplacePlaceholdersInJsonElement(jsonElement, requestBody, logger); + } + + private static object? ReplacePlaceholdersInJsonElement(JsonElement element, dynamic requestBody, ILogger logger) + { + logger.LogTrace("{Method} called", nameof(ReplacePlaceholdersInJsonElement)); + + switch (element.ValueKind) + { + case JsonValueKind.Object: + var resultObj = new Dictionary(); + foreach (var property in element.EnumerateObject()) + { + resultObj[property.Name] = ReplacePlaceholdersInJsonElement(property.Value, requestBody, logger); + } + return resultObj; + + case JsonValueKind.Array: + var resultArray = new List(); + foreach (var item in element.EnumerateArray()) + { + resultArray.Add(ReplacePlaceholdersInJsonElement(item, requestBody, logger)); + } + return resultArray; + case JsonValueKind.String: + return ReplacePlaceholderInString(element.GetString() ?? "", requestBody, logger); + case JsonValueKind.Number: + return element.GetDecimal(); + case JsonValueKind.True: + return true; + case JsonValueKind.False: + return false; + case JsonValueKind.Null: + case JsonValueKind.Undefined: + return null; + default: + return element.ToString(); + } + } + +#pragma warning disable CA1859 // Return type must be object because we can return any type from the request + private static object? ReplacePlaceholderInString(string value, dynamic requestBody, ILogger logger) +#pragma warning restore CA1859 + { + logger.LogTrace("{Method} called", nameof(ReplacePlaceholderInString)); + + logger.LogDebug("Processing value: {Value}", value); + + // Check if the value starts with @request.body. + if (!value.StartsWith("@request.body.", StringComparison.OrdinalIgnoreCase)) + { + logger.LogDebug("Value {Value} does not start with @request.body. Skipping", value); + return value; + } + + // Extract the property path after @request.body. + var propertyPath = value["@request.body.".Length..]; + + logger.LogDebug("Extracted property path: {PropertyPath}", propertyPath); + + return GetValueFromRequestBody(requestBody, propertyPath, logger); + } + + private static object? GetValueFromRequestBody(dynamic requestBody, string propertyPath, ILogger logger) + { + logger.LogTrace("{Method} called", nameof(GetValueFromRequestBody)); + + logger.LogDebug("Getting value for {PropertyPath}", propertyPath); + + try + { + // Split the property path by dots to handle nested properties + var propertyNames = propertyPath.Split('.'); + + // Handle JsonElement + if (requestBody is JsonElement element) + { + return GetNestedValueFromJsonElement(element, propertyNames, logger); + } + else + { + // Handle other dynamic types by converting to JsonElement + var json = JsonSerializer.Serialize(requestBody); + var jsonElement = JsonSerializer.Deserialize(json); + return GetNestedValueFromJsonElement(jsonElement, propertyNames, logger); + } + } + catch + { + // If we can't get the property, return null + } + + return null; + } + + private static object? GetNestedValueFromJsonElement(JsonElement element, string[] propertyNames, ILogger logger) + { + logger.LogTrace("{Method} called", nameof(GetNestedValueFromJsonElement)); + + var current = element; + + // Navigate through the nested properties + foreach (var propertyName in propertyNames) + { + if (current.ValueKind != JsonValueKind.Object) + { + logger.LogDebug("Current JSON element is not an object. Cannot navigate to property {PropertyName}", propertyName); + return null; // Can't navigate further if current element is not an object + } + + if (!current.TryGetProperty(propertyName, out current)) + { + logger.LogDebug("Property {PropertyName} not found in JSON. Returning null", propertyName); + return null; // Property not found + } + } + + return ConvertJsonElementToObject(current, logger); + } + + private static object? ConvertJsonElementToObject(JsonElement element, ILogger logger) + { + logger.LogTrace("{Method} called", nameof(ConvertJsonElementToObject)); + + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.GetDecimal(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null or JsonValueKind.Undefined => null, + // For complex objects/arrays, return the JsonElement itself + // which can be serialized later + JsonValueKind.Object or JsonValueKind.Array => element, + _ => element.ToString(), + }; + } } From 5f0a72e0491e8020d03d9d95c1a5804b06745153 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Wed, 10 Sep 2025 10:55:58 +0200 Subject: [PATCH 2/3] Updates addressing Copilot's feedback --- .../Mocking/MockResponsePlugin.cs | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/DevProxy.Plugins/Mocking/MockResponsePlugin.cs b/DevProxy.Plugins/Mocking/MockResponsePlugin.cs index d71084c6..fac83870 100644 --- a/DevProxy.Plugins/Mocking/MockResponsePlugin.cs +++ b/DevProxy.Plugins/Mocking/MockResponsePlugin.cs @@ -530,7 +530,7 @@ private static void ReplacePlaceholders(MockResponseResponse? response, Request try { - var requestBody = JsonSerializer.Deserialize(request.BodyString, ProxyUtils.JsonSerializerOptions); + var requestBody = JsonSerializer.Deserialize(request.BodyString, ProxyUtils.JsonSerializerOptions); response.Body = ReplacePlaceholdersInObject(response.Body, requestBody, logger); } @@ -543,7 +543,7 @@ private static void ReplacePlaceholders(MockResponseResponse? response, Request logger.LogTrace("Left {Method}", nameof(ReplacePlaceholders)); } - private static object? ReplacePlaceholdersInObject(object? obj, dynamic requestBody, ILogger logger) + private static object? ReplacePlaceholdersInObject(object? obj, JsonElement requestBody, ILogger logger) { logger.LogTrace("{Method} called", nameof(ReplacePlaceholdersInObject)); @@ -570,7 +570,7 @@ private static void ReplacePlaceholders(MockResponseResponse? response, Request return ReplacePlaceholdersInJsonElement(jsonElement, requestBody, logger); } - private static object? ReplacePlaceholdersInJsonElement(JsonElement element, dynamic requestBody, ILogger logger) + private static object? ReplacePlaceholdersInJsonElement(JsonElement element, JsonElement requestBody, ILogger logger) { logger.LogTrace("{Method} called", nameof(ReplacePlaceholdersInJsonElement)); @@ -594,7 +594,7 @@ private static void ReplacePlaceholders(MockResponseResponse? response, Request case JsonValueKind.String: return ReplacePlaceholderInString(element.GetString() ?? "", requestBody, logger); case JsonValueKind.Number: - return element.GetDecimal(); + return GetSafeNumber(element, logger); case JsonValueKind.True: return true; case JsonValueKind.False: @@ -608,7 +608,7 @@ private static void ReplacePlaceholders(MockResponseResponse? response, Request } #pragma warning disable CA1859 // Return type must be object because we can return any type from the request - private static object? ReplacePlaceholderInString(string value, dynamic requestBody, ILogger logger) + private static object? ReplacePlaceholderInString(string value, JsonElement requestBody, ILogger logger) #pragma warning restore CA1859 { logger.LogTrace("{Method} called", nameof(ReplacePlaceholderInString)); @@ -630,7 +630,7 @@ private static void ReplacePlaceholders(MockResponseResponse? response, Request return GetValueFromRequestBody(requestBody, propertyPath, logger); } - private static object? GetValueFromRequestBody(dynamic requestBody, string propertyPath, ILogger logger) + private static object? GetValueFromRequestBody(JsonElement requestBody, string propertyPath, ILogger logger) { logger.LogTrace("{Method} called", nameof(GetValueFromRequestBody)); @@ -657,6 +657,7 @@ private static void ReplacePlaceholders(MockResponseResponse? response, Request catch { // If we can't get the property, return null + logger.LogDebug("Failed to get value for {PropertyPath}. Returning null", propertyPath); } return null; @@ -694,7 +695,7 @@ private static void ReplacePlaceholders(MockResponseResponse? response, Request return element.ValueKind switch { JsonValueKind.String => element.GetString(), - JsonValueKind.Number => element.GetDecimal(), + JsonValueKind.Number => GetSafeNumber(element, logger), JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.Null or JsonValueKind.Undefined => null, @@ -704,4 +705,31 @@ private static void ReplacePlaceholders(MockResponseResponse? response, Request _ => element.ToString(), }; } + + // Attempts to safely extract a number from a JsonElement, falling back to double or string if necessary + private static object? GetSafeNumber(JsonElement element, ILogger logger) + { + logger.LogTrace("{Method} called", nameof(GetSafeNumber)); + + // Try to get as int + if (element.TryGetInt32(out var intValue)) + { + return intValue; + } + if (element.TryGetInt64(out var longValue)) + { + return longValue; + } + if (element.TryGetDecimal(out var decimalValue)) + { + return decimalValue; + } + if (element.TryGetDouble(out var doubleValue)) + { + return doubleValue; + } + + // Fallback: return as string to avoid exceptions + return element.GetRawText(); + } } From 534ec39da29d458ffa899aa27f8e6a4a9049d76f Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Wed, 10 Sep 2025 11:03:56 +0200 Subject: [PATCH 3/3] Addressing further Copilot's feedback --- .../Mocking/MockResponsePlugin.cs | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/DevProxy.Plugins/Mocking/MockResponsePlugin.cs b/DevProxy.Plugins/Mocking/MockResponsePlugin.cs index fac83870..13091ed4 100644 --- a/DevProxy.Plugins/Mocking/MockResponsePlugin.cs +++ b/DevProxy.Plugins/Mocking/MockResponsePlugin.cs @@ -607,7 +607,8 @@ private static void ReplacePlaceholders(MockResponseResponse? response, Request } } -#pragma warning disable CA1859 // Return type must be object because we can return any type from the request +#pragma warning disable CA1859 + // CA1859: This method must return object? because it may return different concrete types (string, int, bool, etc.) based on the JSON content. private static object? ReplacePlaceholderInString(string value, JsonElement requestBody, ILogger logger) #pragma warning restore CA1859 { @@ -640,24 +641,12 @@ private static void ReplacePlaceholders(MockResponseResponse? response, Request { // Split the property path by dots to handle nested properties var propertyNames = propertyPath.Split('.'); - - // Handle JsonElement - if (requestBody is JsonElement element) - { - return GetNestedValueFromJsonElement(element, propertyNames, logger); - } - else - { - // Handle other dynamic types by converting to JsonElement - var json = JsonSerializer.Serialize(requestBody); - var jsonElement = JsonSerializer.Deserialize(json); - return GetNestedValueFromJsonElement(jsonElement, propertyNames, logger); - } + return GetNestedValueFromJsonElement(requestBody, propertyNames, logger); } - catch + catch (Exception ex) { // If we can't get the property, return null - logger.LogDebug("Failed to get value for {PropertyPath}. Returning null", propertyPath); + logger.LogDebug(ex, "Failed to get value for {PropertyPath}. Returning null", propertyPath); } return null;