diff --git a/DevProxy.Integration.Tests/ManipulationAndMockingIntegrationTests.cs b/DevProxy.Integration.Tests/ManipulationAndMockingIntegrationTests.cs index f6f74b09..a4159276 100644 --- a/DevProxy.Integration.Tests/ManipulationAndMockingIntegrationTests.cs +++ b/DevProxy.Integration.Tests/ManipulationAndMockingIntegrationTests.cs @@ -53,6 +53,102 @@ public async Task Rewrite_RewritesRequestUrl_OriginServesRewrittenPath() Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); } + [Theory] + [InlineData("original")] + [InlineData(null)] + public async Task Rewrite_SetsRequestHeader_WhenUrlMatches(string? originalValue) + { + await using var origin = await FakeOrigin.StartAsync(); + var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host); + var config = PluginConfig.FromJson(""" + { + "rewrites": [ + { "in": { "url": "/headers$" }, "out": { "headers": [ { "name": "x-probe", "value": "rewritten" } ] } } + ] + } + """); + var plugin = new RewritePlugin( + SharedHttpClient, + NullLogger.Instance, + urls, + ProxyConfig, + config); + + await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host, [plugin]); + using var client = proxy.CreateHttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, new Uri($"http://{origin.Host}/headers")); + if (originalValue is not null) + { + request.Headers.Add("X-Probe", originalValue); + } + + using var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("probe=rewritten", body); + Assert.Contains(origin.ReceivedRequests, r => r.PathAndQuery == "/headers"); + } + + [Fact] + public async Task Rewrite_RewritesUrlAndHeader_MatchingHeaderAgainstOriginalUrl() + { + await using var origin = await FakeOrigin.StartAsync(); + var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host); + var config = PluginConfig.FromJson(""" + { + "rewrites": [ + { "in": { "url": "/get$" }, "out": { "url": "/headers", "headers": [ { "name": "X-Probe", "value": "rewritten" } ] } } + ] + } + """); + var plugin = new RewritePlugin( + 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.Contains(origin.ReceivedRequests, r => r.PathAndQuery == "/headers"); + Assert.Equal("probe=rewritten", body); + } + + [Fact] + public async Task Rewrite_LeavesRequestHeader_WhenUrlDoesNotMatch() + { + await using var origin = await FakeOrigin.StartAsync(); + var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host); + var config = PluginConfig.FromJson(""" + { + "rewrites": [ + { "in": { "url": "/other$" }, "out": { "headers": [ { "name": "X-Probe", "value": "rewritten" } ] } } + ] + } + """); + var plugin = new RewritePlugin( + SharedHttpClient, + NullLogger.Instance, + urls, + ProxyConfig, + config); + + await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host, [plugin]); + using var client = proxy.CreateHttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, new Uri($"http://{origin.Host}/headers")); + request.Headers.Add("X-Probe", "original"); + + using var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + Assert.Equal("probe=original", body); + } + [Fact] public async Task Rewrite_RewritesWebSocketUrl_OriginReceivesRewrittenPath() { diff --git a/DevProxy.Plugins/Manipulation/RewritePlugin.cs b/DevProxy.Plugins/Manipulation/RewritePlugin.cs index 93780520..2f208c70 100644 --- a/DevProxy.Plugins/Manipulation/RewritePlugin.cs +++ b/DevProxy.Plugins/Manipulation/RewritePlugin.cs @@ -12,9 +12,16 @@ namespace DevProxy.Plugins.Manipulation; +public sealed class RewriteHeader +{ + public string? Name { get; set; } + public string? Value { get; set; } +} + public sealed class RewriteRule { public string? Url { get; set; } + public IEnumerable? Headers { get; set; } } public sealed class RequestRewrite @@ -82,22 +89,53 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca foreach (var rewrite in Configuration.Rewrites) { - if (string.IsNullOrEmpty(rewrite.In?.Url) || - string.IsNullOrEmpty(rewrite.Out?.Url)) + if (string.IsNullOrEmpty(rewrite.In?.Url) || rewrite.Out is null) + { + continue; + } + + var headers = rewrite.Out.Headers? + .Where(h => h is not null && !string.IsNullOrEmpty(h.Name) && h.Value is not null) + .ToArray() ?? []; + if (string.IsNullOrEmpty(rewrite.Out.Url) && headers.Length == 0) { continue; } - var newUrl = Regex.Replace(request.Url, rewrite.In.Url, rewrite.Out.Url, RegexOptions.IgnoreCase); + // Match against the URL before this rule rewrites it, so that + // header rewrites apply to the same requests as the URL rewrite. + var urlMatches = Regex.IsMatch(request.Url, rewrite.In.Url, RegexOptions.IgnoreCase); + + if (!string.IsNullOrEmpty(rewrite.Out.Url)) + { + var newUrl = Regex.Replace(request.Url, rewrite.In.Url, rewrite.Out.Url, RegexOptions.IgnoreCase); + + if (request.Url.Equals(newUrl, StringComparison.OrdinalIgnoreCase)) + { + Logger.LogRequest($"{rewrite.In.Url}", MessageType.Skipped, new LoggingContext(e.ProxySession)); + } + else + { + Logger.LogRequest($"{rewrite.In.Url} > {newUrl}", MessageType.Processed, new LoggingContext(e.ProxySession)); + request.Url = newUrl; + } + } + + if (headers.Length == 0) + { + continue; + } - if (request.Url.Equals(newUrl, StringComparison.OrdinalIgnoreCase)) + if (!urlMatches) { - Logger.LogRequest($"{rewrite.In?.Url}", MessageType.Skipped, new LoggingContext(e.ProxySession)); + Logger.LogRequest($"{rewrite.In.Url} (headers)", MessageType.Skipped, new LoggingContext(e.ProxySession)); + continue; } - else + + foreach (var header in headers) { - Logger.LogRequest($"{rewrite.In?.Url} > {newUrl}", MessageType.Processed, new LoggingContext(e.ProxySession)); - request.Url = newUrl; + request.Headers.Replace(header.Name!, header.Value!); + Logger.LogRequest($"{rewrite.In.Url} > header {header.Name}", MessageType.Processed, new LoggingContext(e.ProxySession)); } } diff --git a/schemas/v4.0.0/rewriteplugin.rewritesfile.schema.json b/schemas/v4.0.0/rewriteplugin.rewritesfile.schema.json index b2cbcdd1..9a46c0e5 100644 --- a/schemas/v4.0.0/rewriteplugin.rewritesfile.schema.json +++ b/schemas/v4.0.0/rewriteplugin.rewritesfile.schema.json @@ -33,10 +33,32 @@ "url": { "type": "string", "pattern": "^.*$", - "description": "URL to rewrite the request to. Can use capture groups from the 'in' pattern." + "description": "URL to rewrite the request to. Can use capture groups from the 'in' pattern. If omitted, the request URL isn't changed." + }, + "headers": { + "type": "array", + "description": "Request headers to set on requests matching the 'in' URL. Replaces the value of an existing header (matched case-insensitively) or adds the header if it's missing.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^.+$", + "description": "Name of the request header." + }, + "value": { + "type": "string", + "description": "Literal value to set for the request header." + } + }, + "required": ["name", "value"] + } } }, - "required": ["url"] + "anyOf": [ + { "required": ["url"] }, + { "required": ["headers"] } + ] } }, "required": ["in", "out"] diff --git a/skills/dev-proxy/references/configuration.md b/skills/dev-proxy/references/configuration.md index 049e0318..ce5bcb9e 100644 --- a/skills/dev-proxy/references/configuration.md +++ b/skills/dev-proxy/references/configuration.md @@ -302,7 +302,7 @@ None of these have configuration properties. ### RewritePlugin -Rewrites request URLs using regex capture groups. +Rewrites request URLs using regex capture groups and sets request header values. Each rule matches on `in.url`; `out.url` (optional) rewrites the URL, and `out.headers` (optional, `[{ "name", "value" }]`) replaces the header value or adds the header if it's missing. | Property | Default | Description | |----------|---------|-------------| diff --git a/skills/dev-proxy/references/plugin-catalog.md b/skills/dev-proxy/references/plugin-catalog.md index a5970f0f..f67f7cb7 100644 --- a/skills/dev-proxy/references/plugin-catalog.md +++ b/skills/dev-proxy/references/plugin-catalog.md @@ -117,7 +117,7 @@ Simulates token-based rate limiting for LLM APIs. ### RewritePlugin -Rewrites request URLs using regex capture groups. +Rewrites request URLs using regex capture groups and sets request header values. Each rule matches on `in.url`; `out.url` (optional) rewrites the URL, and `out.headers` (optional, `[{ "name", "value" }]`) replaces the header value or adds the header if it's missing. | Property | Default | Description | |----------|---------|-------------|