Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions DevProxy.Abstractions.Tests/Utils/ProxyUtilsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<object>("\"hello\"")));

[Fact]
public void SerializeBody_ObjectIsSerializedAsJson()
{
var body = ProxyUtils.SerializeBody(JsonSerializer.Deserialize<object>("""{ "message": "hello" }"""));

using var doc = JsonDocument.Parse(body);
Assert.Equal("hello", doc.RootElement.GetProperty("message").GetString());
}
}
13 changes: 13 additions & 0 deletions DevProxy.Abstractions/Utils/ProxyUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,19 @@ static ProxyUtils()
JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
}

/// <summary>
/// Converts a configured response body to the string sent to the client.
/// String bodies are returned as-is; other values are serialized as JSON.
/// </summary>
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>() ?? string.Empty,
_ => JsonSerializer.Serialize(body, JsonSerializerOptions)
};

public static bool IsGraphRequest(IHttpRequest request)
{
ArgumentNullException.ThrowIfNull(request);
Expand Down
90 changes: 90 additions & 0 deletions DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<GenericRandomErrorPlugin>.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*")]
Expand Down Expand Up @@ -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<RateLimitingPlugin>.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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
8 changes: 3 additions & 5 deletions DevProxy.Plugins/Behavior/GenericRandomErrorPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 1 addition & 3 deletions DevProxy.Plugins/Behavior/RateLimitingPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
8 changes: 3 additions & 5 deletions DevProxy.Plugins/Mocking/MockResponsePlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
{

Expand Down
Loading