diff --git a/DevProxy.Abstractions.Tests/Utils/ProxyUtilsTests.cs b/DevProxy.Abstractions.Tests/Utils/ProxyUtilsTests.cs index 8bba1649..6016c687 100644 --- a/DevProxy.Abstractions.Tests/Utils/ProxyUtilsTests.cs +++ b/DevProxy.Abstractions.Tests/Utils/ProxyUtilsTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Text.Json; using DevProxy.Abstractions.Utils; using Xunit; @@ -76,4 +77,25 @@ public void GetFullPath_ResolvesTildeSlashToHomeDirectoryInsteadOfConfigFolder() System.IO.Path.GetFullPath(System.IO.Path.Combine(home, "mocks", "mocks.json")), ProxyUtils.GetFullPath("~/mocks/mocks.json", configFile)); } + + [Fact] + public void SerializeBody_NullReturnsEmptyString() => + Assert.Equal(string.Empty, ProxyUtils.SerializeBody(null)); + + [Fact] + public void SerializeBody_StringIsReturnedAsIs() => + Assert.Equal("hello", ProxyUtils.SerializeBody("hello")); + + [Fact] + public void SerializeBody_JsonStringElementIsReturnedAsIs() => + Assert.Equal("hello", ProxyUtils.SerializeBody(JsonSerializer.Deserialize("\"hello\""))); + + [Fact] + public void SerializeBody_ObjectIsSerializedAsJson() + { + var body = ProxyUtils.SerializeBody(JsonSerializer.Deserialize("""{ "message": "hello" }""")); + + using var doc = JsonDocument.Parse(body); + Assert.Equal("hello", doc.RootElement.GetProperty("message").GetString()); + } } diff --git a/DevProxy.Abstractions/Utils/ProxyUtils.cs b/DevProxy.Abstractions/Utils/ProxyUtils.cs index a4b1ed1b..7c5af680 100644 --- a/DevProxy.Abstractions/Utils/ProxyUtils.cs +++ b/DevProxy.Abstractions/Utils/ProxyUtils.cs @@ -104,6 +104,19 @@ static ProxyUtils() JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); } + /// + /// Converts a configured response body to the string sent to the client. + /// String bodies are returned as-is; other values are serialized as JSON. + /// + public static string SerializeBody(object? body) => body switch + { + null => string.Empty, + string s => s, + JsonElement { ValueKind: JsonValueKind.String } el => el.GetString() ?? string.Empty, + JValue { Type: JTokenType.String } jv => jv.Value() ?? string.Empty, + _ => JsonSerializer.Serialize(body, JsonSerializerOptions) + }; + public static bool IsGraphRequest(IHttpRequest request) { ArgumentNullException.ThrowIfNull(request); diff --git a/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs b/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs index 1e4fa357..13001762 100644 --- a/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs +++ b/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs @@ -66,6 +66,42 @@ public async Task GenericRandomError_Rate100_OverridesOriginWithConfiguredError( Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); } + [Fact] + public async Task GenericRandomError_StringBody_IsSentAsIs() + { + await using var origin = await FakeOrigin.StartAsync(); + var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host); + + var config = PluginConfig.FromJson($$""" + { + "rate": 100, + "errors": [ + { + "request": { "url": "http://{{origin.Host}}/*" }, + "responses": [ { "statusCode": 503, "body": "service down" } ] + } + ] + } + """); + var plugin = new GenericRandomErrorPlugin( + SharedHttpClient, + NullLogger.Instance, + urls, + ProxyConfig, + config); + + await using var proxy = await KestrelProxyHarness.StartAsync( + origin.Host, [plugin]); + using var client = proxy.CreateHttpClient(); + + using var response = await client.GetAsync(new Uri($"http://{origin.Host}/get")); + var body = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + // string bodies must not be JSON-serialized (no surrounding quotes) + Assert.Equal("service down", body); + } + [Theory] [InlineData("/Get")] [InlineData("/Ge*")] @@ -268,6 +304,60 @@ public async Task RateLimiting_ThrottlesOnceResourcesAreExhausted() Assert.Equal(HttpStatusCode.TooManyRequests, second.StatusCode); } + [Fact] + public async Task RateLimiting_CustomResponseStringBody_IsSentAsIs() + { + await using var origin = await FakeOrigin.StartAsync(); + var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host); + + var dir = Directory.CreateTempSubdirectory("devproxy-ratelimit-"); + try + { + var responseFile = Path.Combine(dir.FullName, "rate-limit-response.json"); + await File.WriteAllTextAsync(responseFile, """ + { "statusCode": 403, "body": "slow down" } + """); + var proxyConfig = new TestProxyConfiguration + { + ConfigFile = Path.Combine(dir.FullName, "devproxyrc.json"), + }; + var plugin = new RateLimitingPlugin( + SharedHttpClient, + NullLogger.Instance, + urls, + proxyConfig, + PluginConfig.FromJson(""" + { + "rateLimit": 2, + "costPerRequest": 2, + "resetTimeWindowSeconds": 300, + "whenLimitExceeded": "custom", + "customResponseFile": "rate-limit-response.json" + } + """)); + + using var host = new PluginTestHost(proxyConfig); + await plugin.InitializeAsync(host.CreateInitArgs(), CancellationToken.None); + + await using var proxy = await KestrelProxyHarness.StartAsync( + origin.Host, [plugin]); + using var client = proxy.CreateHttpClient(); + + using var first = await client.GetAsync(new Uri($"http://{origin.Host}/get")); + using var throttled = await client.GetAsync(new Uri($"http://{origin.Host}/get")); + var body = await throttled.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, throttled.StatusCode); + // string bodies must not be JSON-serialized (no surrounding quotes) + Assert.Equal("slow down", body); + } + finally + { + dir.Delete(recursive: true); + } + } + [Fact] public async Task RateLimitingPlusRetryAfter_ThrottledResponseCarriesRetryAfter() { diff --git a/DevProxy.Integration.Tests/ManipulationAndMockingIntegrationTests.cs b/DevProxy.Integration.Tests/ManipulationAndMockingIntegrationTests.cs index 9abdb867..461022f1 100644 --- a/DevProxy.Integration.Tests/ManipulationAndMockingIntegrationTests.cs +++ b/DevProxy.Integration.Tests/ManipulationAndMockingIntegrationTests.cs @@ -317,7 +317,8 @@ public async Task MockResponse_ShortCircuitsWithConfiguredMock() // /get normally returns 200 "hello get"; the mock overrides it. Assert.Equal(HttpStatusCode.Created, response.StatusCode); - Assert.Contains("mocked-by-test", body, StringComparison.Ordinal); + // string bodies must not be JSON-serialized (no surrounding quotes) + Assert.Equal("mocked-by-test", body); } [Theory] diff --git a/DevProxy.Plugins/Behavior/GenericRandomErrorPlugin.cs b/DevProxy.Plugins/Behavior/GenericRandomErrorPlugin.cs index e488c627..68ff73f5 100644 --- a/DevProxy.Plugins/Behavior/GenericRandomErrorPlugin.cs +++ b/DevProxy.Plugins/Behavior/GenericRandomErrorPlugin.cs @@ -265,16 +265,14 @@ private void UpdateProxyResponse(ProxyRequestArgs e, GenericErrorResponseRespons } var statusCode = (HttpStatusCode)(error.StatusCode ?? 400); - var body = error.Body is null ? string.Empty : JsonSerializer.Serialize(error.Body, ProxyUtils.JsonSerializerOptions); - // we get a JSON string so need to start with the opening quote - if (body.StartsWith("\"@")) + string body = ProxyUtils.SerializeBody(error.Body); + if (body.StartsWith('@')) { // we've got a mock body starting with @-token which means we're sending // a response from a file on disk // if we can read the file, we can immediately send the response and // skip the rest of the logic in this method - // remove the surrounding quotes and the @-token - var filePath = Path.Combine(Path.GetDirectoryName(Configuration.ErrorsFile) ?? "", ProxyUtils.ReplacePathTokens(body.Trim('"').Substring(1))); + var filePath = Path.Combine(Path.GetDirectoryName(Configuration.ErrorsFile) ?? "", ProxyUtils.ReplacePathTokens(body[1..])); if (!File.Exists(filePath)) { Logger.LogError("File {FilePath} not found. Serving file path in the mock response", (string?)filePath); diff --git a/DevProxy.Plugins/Behavior/RateLimitingPlugin.cs b/DevProxy.Plugins/Behavior/RateLimitingPlugin.cs index 46cd78ac..686cdd44 100644 --- a/DevProxy.Plugins/Behavior/RateLimitingPlugin.cs +++ b/DevProxy.Plugins/Behavior/RateLimitingPlugin.cs @@ -177,9 +177,7 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca )); } - string body = Configuration.CustomResponse.Body is not null ? - JsonSerializer.Serialize(Configuration.CustomResponse.Body, ProxyUtils.JsonSerializerOptions) : - ""; + string body = ProxyUtils.SerializeBody(Configuration.CustomResponse.Body); e.ProxySession.Respond(body, responseCode, headers); state.HasBeenSet = true; } diff --git a/DevProxy.Plugins/Mocking/MockResponsePlugin.cs b/DevProxy.Plugins/Mocking/MockResponsePlugin.cs index 60834207..1fd6d32a 100644 --- a/DevProxy.Plugins/Mocking/MockResponsePlugin.cs +++ b/DevProxy.Plugins/Mocking/MockResponsePlugin.cs @@ -380,16 +380,14 @@ private void ProcessMockResponseInternal(ProxyRequestArgs e, MockResponse matchi if (matchingResponse.Response?.Body is not null) { - var bodyString = JsonSerializer.Serialize(matchingResponse.Response.Body, ProxyUtils.JsonSerializerOptions) as string; - // we get a JSON string so need to start with the opening quote - if (bodyString?.StartsWith("\"@", StringComparison.OrdinalIgnoreCase) ?? false) + string bodyString = ProxyUtils.SerializeBody(matchingResponse.Response.Body); + if (bodyString.StartsWith('@')) { // we've got a mock body starting with @-token which means we're sending // a response from a file on disk // if we can read the file, we can immediately send the response and // skip the rest of the logic in this method - // remove the surrounding quotes and the @-token - var filePath = Path.Combine(Path.GetDirectoryName(Configuration.MocksFile) ?? "", ProxyUtils.ReplacePathTokens(bodyString.Trim('"')[1..])); + var filePath = Path.Combine(Path.GetDirectoryName(Configuration.MocksFile) ?? "", ProxyUtils.ReplacePathTokens(bodyString[1..])); if (!File.Exists(filePath)) {