diff --git a/dev-proxy-abstractions/IProxyLogger.cs b/dev-proxy-abstractions/IProxyLogger.cs index 1666d610..5b310652 100644 --- a/dev-proxy-abstractions/IProxyLogger.cs +++ b/dev-proxy-abstractions/IProxyLogger.cs @@ -17,7 +17,8 @@ public enum MessageType Mocked, InterceptedResponse, FinishedProcessingRequest, - Skipped + Skipped, + Processed } public class LoggingContext(SessionEventArgs session) diff --git a/dev-proxy-plugins/Processing/RewritePlugin.cs b/dev-proxy-plugins/Processing/RewritePlugin.cs new file mode 100644 index 00000000..066ba218 --- /dev/null +++ b/dev-proxy-plugins/Processing/RewritePlugin.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.DevProxy.Abstractions; +using System.Text.RegularExpressions; + +namespace Microsoft.DevProxy.Plugins.Processing; + +public class RewriteRule +{ + public string? Url { get; set; } +} + +public class RequestRewrite +{ + public RewriteRule? In { get; set; } + public RewriteRule? Out { get; set; } +} + +public class RewritePluginConfiguration +{ + public IEnumerable Rewrites { get; set; } = []; + public string RewritesFile { get; set; } = "rewrites.json"; +} + +public class RewritePlugin(IPluginEvents pluginEvents, IProxyContext context, ILogger logger, ISet urlsToWatch, IConfigurationSection? configSection = null) : BaseProxyPlugin(pluginEvents, context, logger, urlsToWatch, configSection) +{ + public override string Name => nameof(RewritePlugin); + private readonly RewritePluginConfiguration _configuration = new(); + private RewritesLoader? _loader = null; + + public override async Task RegisterAsync() + { + await base.RegisterAsync(); + + ConfigSection?.Bind(_configuration); + _loader = new RewritesLoader(Logger, _configuration); + + PluginEvents.BeforeRequest += BeforeRequestAsync; + + // make the rewrites file path relative to the configuration file + _configuration.RewritesFile = Path.GetFullPath( + ProxyUtils.ReplacePathTokens(_configuration.RewritesFile), + Path.GetDirectoryName(Context.Configuration.ConfigFile ?? string.Empty) ?? string.Empty + ); + + _loader?.InitResponsesWatcher(); + } + + private Task BeforeRequestAsync(object sender, ProxyRequestArgs e) + { + if (UrlsToWatch is null || + !e.HasRequestUrlMatch(UrlsToWatch)) + { + Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session)); + return Task.CompletedTask; + } + + if (_configuration.Rewrites is null || + !_configuration.Rewrites.Any()) + { + Logger.LogRequest("No rewrites configured", MessageType.Skipped, new LoggingContext(e.Session)); + return Task.CompletedTask; + } + + var request = e.Session.HttpClient.Request; + + foreach (var rewrite in _configuration.Rewrites) + { + if (string.IsNullOrEmpty(rewrite.In?.Url) || + string.IsNullOrEmpty(rewrite.Out?.Url)) + { + continue; + } + + 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.Session)); + } + else + { + Logger.LogRequest($"{rewrite.In?.Url} > {newUrl}", MessageType.Processed, new LoggingContext(e.Session)); + request.Url = newUrl; + } + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/dev-proxy-plugins/Processing/RewritesLoader.cs b/dev-proxy-plugins/Processing/RewritesLoader.cs new file mode 100644 index 00000000..674ffaf7 --- /dev/null +++ b/dev-proxy-plugins/Processing/RewritesLoader.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DevProxy.Abstractions; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace Microsoft.DevProxy.Plugins.Processing; + +internal class RewritesLoader(ILogger logger, RewritePluginConfiguration configuration) : IDisposable +{ + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly RewritePluginConfiguration _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + + private string RewritesFilePath => _configuration.RewritesFile; + private FileSystemWatcher? _watcher; + + public void LoadRewrites() + { + if (!File.Exists(RewritesFilePath)) + { + _logger.LogWarning("File {configurationFile} not found. No rewrites will be provided", _configuration.RewritesFile); + _configuration.Rewrites = []; + return; + } + + try + { + using var stream = new FileStream(RewritesFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream); + var RewritesString = reader.ReadToEnd(); + var rewritesConfig = JsonSerializer.Deserialize(RewritesString, ProxyUtils.JsonSerializerOptions); + IEnumerable? configRewrites = rewritesConfig?.Rewrites; + if (configRewrites is not null) + { + _configuration.Rewrites = configRewrites; + _logger.LogInformation("Rewrites for {configResponseCount} url patterns loaded from {RewritesFile}", configRewrites.Count(), _configuration.RewritesFile); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "An error has occurred while reading {RewritesFile}:", _configuration.RewritesFile); + } + } + + public void InitResponsesWatcher() + { + if (_watcher is not null) + { + return; + } + + string path = Path.GetDirectoryName(RewritesFilePath) ?? throw new InvalidOperationException($"{RewritesFilePath} is an invalid path"); + if (!File.Exists(RewritesFilePath)) + { + _logger.LogWarning("File {RewritesFile} not found. No rewrites will be provided", _configuration.RewritesFile); + _configuration.Rewrites = []; + return; + } + + _watcher = new FileSystemWatcher(Path.GetFullPath(path)) + { + NotifyFilter = NotifyFilters.CreationTime + | NotifyFilters.FileName + | NotifyFilters.LastWrite + | NotifyFilters.Size, + Filter = Path.GetFileName(RewritesFilePath) + }; + _watcher.Changed += ResponsesFile_Changed; + _watcher.Created += ResponsesFile_Changed; + _watcher.Deleted += ResponsesFile_Changed; + _watcher.Renamed += ResponsesFile_Changed; + _watcher.EnableRaisingEvents = true; + + LoadRewrites(); + } + + private void ResponsesFile_Changed(object sender, FileSystemEventArgs e) + { + LoadRewrites(); + } + + public void Dispose() + { + _watcher?.Dispose(); + } +} diff --git a/dev-proxy/Logging/ProxyConsoleFormatter.cs b/dev-proxy/Logging/ProxyConsoleFormatter.cs index 26c8eddc..43091bb5 100644 --- a/dev-proxy/Logging/ProxyConsoleFormatter.cs +++ b/dev-proxy/Logging/ProxyConsoleFormatter.cs @@ -230,13 +230,14 @@ private static string GetMessageTypeString(MessageType messageType) { MessageType.InterceptedRequest => "req", MessageType.InterceptedResponse => "res", - MessageType.PassedThrough => "api", + MessageType.PassedThrough => "pass", MessageType.Chaos => "oops", MessageType.Warning => "warn", MessageType.Mocked => "mock", MessageType.Failed => "fail", MessageType.Tip => "tip", MessageType.Skipped => "skip", + MessageType.Processed => "proc", _ => " " }; } @@ -251,6 +252,7 @@ private static (ConsoleColor bg, ConsoleColor fg) GetMessageTypeColor(MessageTyp MessageType.InterceptedRequest => (bgColor, ConsoleColor.Gray), MessageType.PassedThrough => (ConsoleColor.Gray, fgColor), MessageType.Skipped => (bgColor, ConsoleColor.Gray), + MessageType.Processed => (ConsoleColor.DarkGreen, fgColor), MessageType.Chaos => (ConsoleColor.DarkRed, fgColor), MessageType.Warning => (ConsoleColor.DarkYellow, fgColor), MessageType.Mocked => (ConsoleColor.DarkMagenta, fgColor), diff --git a/schemas/v0.23.0/rewriteplugin.schema.json b/schemas/v0.23.0/rewriteplugin.schema.json new file mode 100644 index 00000000..17456776 --- /dev/null +++ b/schemas/v0.23.0/rewriteplugin.schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy RewritePlugin rewrite rules", + "description": "Rewrite rules for the Dev Proxy RewritePlugin", + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "rewrites": { + "type": "array", + "items": { + "type": "object", + "properties": { + "in": { + "type": "object", + "properties": { + "url": { + "type": "string", + "pattern": "^.+$" + } + }, + "required": ["url"] + }, + "out": { + "type": "object", + "properties": { + "url": { + "type": "string", + "pattern": "^.*$" + } + }, + "required": ["url"] + } + }, + "required": ["in", "out"] + } + } + }, + "required": [ + "rewrites" + ], + "additionalProperties": false +} \ No newline at end of file