From 69b1d6e944d9ac30667cc3bb3b23f17a84fe9a4d Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Mon, 23 Jun 2025 15:24:29 +0200 Subject: [PATCH] Extends MockResponsePlugin with a command to generate mocks from HTTP responses. Closes #1261 --- DevProxy.Abstractions/Models/MockResponse.cs | 99 +++++++++++++++++++ DevProxy.Plugins/DevProxy.Plugins.csproj | 4 + .../Mocking/MockResponsePlugin.cs | 99 +++++++++++++++++++ DevProxy.Plugins/packages.lock.json | 11 ++- DevProxy/DevProxy.csproj | 1 + DevProxy/packages.lock.json | 11 ++- 6 files changed, 215 insertions(+), 10 deletions(-) diff --git a/DevProxy.Abstractions/Models/MockResponse.cs b/DevProxy.Abstractions/Models/MockResponse.cs index 25b94ca1..f5abbed4 100644 --- a/DevProxy.Abstractions/Models/MockResponse.cs +++ b/DevProxy.Abstractions/Models/MockResponse.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using DevProxy.Abstractions.Utils; +using Microsoft.Extensions.Logging; using System.Text.Json; namespace DevProxy.Abstractions.Models; @@ -16,6 +18,103 @@ public object Clone() var json = JsonSerializer.Serialize(this); return JsonSerializer.Deserialize(json) ?? new MockResponse(); } + + public static MockResponse FromHttpResponse(string httpResponse, ILogger logger) + { + logger.LogTrace("{Method} called", nameof(FromHttpResponse)); + + if (string.IsNullOrWhiteSpace(httpResponse)) + { + throw new ArgumentException("HTTP response cannot be null or empty.", nameof(httpResponse)); + } + if (!httpResponse.StartsWith("HTTP/", StringComparison.Ordinal)) + { + throw new ArgumentException("Invalid HTTP response format. HTTP response must begin with 'HTTP/'", nameof(httpResponse)); + } + + var lines = httpResponse.Split(["\r\n", "\n"], StringSplitOptions.TrimEntries); + var statusCode = 200; + List? responseHeaders = null; + dynamic? body = null; + + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i]; + logger.LogTrace("Processing line {LineNumber}: {LineContent}", i + 1, line); + + if (i == 0) + { + // First line is the status line + var parts = line.Split(' ', 3); + if (parts.Length < 2) + { + throw new ArgumentException("Invalid HTTP response format. First line must contain at least HTTP version and status code.", nameof(httpResponse)); + } + + statusCode = int.TryParse(parts[1], out var _statusCode) ? _statusCode : 200; + } + else if (string.IsNullOrEmpty(line)) + { + // empty line indicates the end of headers and the start of the body + var bodyContents = string.Join("\n", lines.Skip(i + 1)); + if (string.IsNullOrWhiteSpace(bodyContents)) + { + continue; + } + + var contentType = responseHeaders?.FirstOrDefault(h => h.Name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase))?.Value; + if (contentType is not null && contentType.Contains("application/json", StringComparison.OrdinalIgnoreCase)) + { + try + { + body = JsonSerializer.Deserialize(bodyContents, ProxyUtils.JsonSerializerOptions); + } + catch (JsonException ex) + { + logger.LogError(ex, "Failed to deserialize JSON body from HTTP response"); + body = bodyContents; + } + } + else + { + body = bodyContents; + } + + break; + } + else + { + // Headers + var headerParts = line.Split(':', 2); + if (headerParts.Length < 2) + { + logger.LogError($"Invalid HTTP response header format"); + continue; + } + + responseHeaders ??= []; + responseHeaders.Add(new(headerParts[0].Trim(), headerParts[1].Trim())); + } + } + + var mockResponse = new MockResponse + { + Request = new() + { + Url = "*" + }, + Response = new() + { + StatusCode = statusCode, + Headers = responseHeaders, + Body = body + } + }; + + logger.LogTrace("Left {Method}", nameof(FromHttpResponse)); + + return mockResponse; + } } public class MockResponseRequest diff --git a/DevProxy.Plugins/DevProxy.Plugins.csproj b/DevProxy.Plugins/DevProxy.Plugins.csproj index 8b657b48..79a8bcde 100644 --- a/DevProxy.Plugins/DevProxy.Plugins.csproj +++ b/DevProxy.Plugins/DevProxy.Plugins.csproj @@ -28,6 +28,10 @@ false runtime + + false + runtime + false runtime diff --git a/DevProxy.Plugins/Mocking/MockResponsePlugin.cs b/DevProxy.Plugins/Mocking/MockResponsePlugin.cs index 681fe961..033203aa 100644 --- a/DevProxy.Plugins/Mocking/MockResponsePlugin.cs +++ b/DevProxy.Plugins/Mocking/MockResponsePlugin.cs @@ -10,6 +10,7 @@ using DevProxy.Plugins.Models; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileSystemGlobbing; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.CommandLine; @@ -60,6 +61,8 @@ public class MockResponsePlugin( private readonly ConcurrentDictionary _appliedMocks = []; private MockResponsesLoader? _loader; + private Argument>? _httpResponseFilesArgument; + private Option? _httpResponseMocksFileNameOption; public override string Name => nameof(MockResponsePlugin); @@ -89,6 +92,33 @@ public override Option[] GetOptions() return [_noMocks, _mocksFile]; } + public override Command[] GetCommands() + { + var mocksCommand = new Command("mocks", "Manage mock responses"); + var mocksFromHttpResponseCommand = new Command("from-http-responses", "Create a mock response from HTTP responses"); + _httpResponseFilesArgument = new Argument>("http-response-files") + { + Arity = ArgumentArity.OneOrMore, + Description = "Glob pattern to the file(s) containing HTTP responses to create mock responses from", + }; + mocksFromHttpResponseCommand.Add(_httpResponseFilesArgument); + _httpResponseMocksFileNameOption = new Option("--mocks-file") + { + HelpName = "mocks file", + Arity = ArgumentArity.ExactlyOne, + Description = "File to save the generated mock responses to", + Required = true + }; + mocksFromHttpResponseCommand.Add(_httpResponseMocksFileNameOption); + mocksFromHttpResponseCommand.SetAction(GenerateMocksFromHttpResponsesAsync); + + mocksCommand.AddCommands(new[] + { + mocksFromHttpResponseCommand + }.OrderByName()); + return [mocksCommand]; + } + public override void OptionsLoaded(OptionsLoadedArgs e) { ArgumentNullException.ThrowIfNull(e); @@ -392,6 +422,75 @@ private void ProcessMockResponseInternal(ProxyRequestArgs e, MockResponse matchi Logger.LogRequest($"{matchingResponse.Response?.StatusCode ?? 200} {matchingResponse.Request?.Url}", MessageType.Mocked, new(e.Session)); } + private async Task GenerateMocksFromHttpResponsesAsync(ParseResult parseResult) + { + Logger.LogTrace("{Method} called", nameof(GenerateMocksFromHttpResponsesAsync)); + + if (_httpResponseFilesArgument is null) + { + throw new InvalidOperationException("HTTP response files argument is not initialized."); + } + if (_httpResponseMocksFileNameOption is null) + { + throw new InvalidOperationException("HTTP response mocks file name option is not initialized."); + } + + var outputFilePath = parseResult.GetValue(_httpResponseMocksFileNameOption); + if (string.IsNullOrEmpty(outputFilePath)) + { + Logger.LogError("No output file path provided for mock responses."); + return; + } + + var httpResponseFiles = parseResult.GetValue(_httpResponseFilesArgument); + if (httpResponseFiles is null || !httpResponseFiles.Any()) + { + Logger.LogError("No HTTP response files provided."); + return; + } + + var matcher = new Matcher(); + matcher.AddIncludePatterns(httpResponseFiles); + + var matchingFiles = matcher.GetResultsInFullPath("."); + if (!matchingFiles.Any()) + { + Logger.LogError("No matching HTTP response files found."); + return; + } + + Logger.LogInformation("Found {FileCount} matching HTTP response files", matchingFiles.Count()); + Logger.LogDebug("Matching files: {Files}", string.Join(", ", matchingFiles)); + + var mockResponses = new List(); + foreach (var file in matchingFiles) + { + Logger.LogInformation("Processing file: {File}", Path.GetRelativePath(".", file)); + try + { + mockResponses.Add(MockResponse.FromHttpResponse(await File.ReadAllTextAsync(file), Logger)); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error processing file {File}", file); + continue; + } + } + + var mocksFile = new MockResponseConfiguration + { + Mocks = mockResponses + }; + await File.WriteAllTextAsync( + outputFilePath, + JsonSerializer.Serialize(mocksFile, ProxyUtils.JsonSerializerOptions) + ); + + Logger.LogInformation("Generated mock responses saved to {OutputFile}", outputFilePath); + + Logger.LogTrace("Left {Method}", nameof(GenerateMocksFromHttpResponsesAsync)); + } + private static bool HasMatchingBody(MockResponse mockResponse, Request request) { if (request.Method == "GET") diff --git a/DevProxy.Plugins/packages.lock.json b/DevProxy.Plugins/packages.lock.json index 7e1dcbf5..9a66e6b3 100644 --- a/DevProxy.Plugins/packages.lock.json +++ b/DevProxy.Plugins/packages.lock.json @@ -41,6 +41,12 @@ "Microsoft.Extensions.Primitives": "9.0.4" } }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Direct", + "requested": "[9.0.6, )", + "resolved": "9.0.6", + "contentHash": "1HJCAbwukNEoYbHgHbKHmenU0V/0huw8+i7Qtf5rLUG1E+3kEwRJQxpwD3wbTEagIgPSQisNgJTvmUX9yYVc6g==" + }, "Microsoft.IdentityModel.Protocols.OpenIdConnect": { "type": "Direct", "requested": "[8.8.0, )", @@ -304,11 +310,6 @@ "Microsoft.Extensions.Primitives": "9.0.4" } }, - "Microsoft.Extensions.FileSystemGlobbing": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "05Lh2ItSk4mzTdDWATW9nEcSybwprN8Tz42Fs5B+jwdXUpauktdAQUI1Am4sUQi2C63E5hvQp8gXvfwfg9mQGQ==" - }, "Microsoft.Extensions.Logging": { "type": "Transitive", "resolved": "9.0.4", diff --git a/DevProxy/DevProxy.csproj b/DevProxy/DevProxy.csproj index 6f098405..b251a437 100644 --- a/DevProxy/DevProxy.csproj +++ b/DevProxy/DevProxy.csproj @@ -37,6 +37,7 @@ + diff --git a/DevProxy/packages.lock.json b/DevProxy/packages.lock.json index 015d8233..88fef2c4 100644 --- a/DevProxy/packages.lock.json +++ b/DevProxy/packages.lock.json @@ -62,6 +62,12 @@ "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4" } }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Direct", + "requested": "[9.0.6, )", + "resolved": "9.0.6", + "contentHash": "1HJCAbwukNEoYbHgHbKHmenU0V/0huw8+i7Qtf5rLUG1E+3kEwRJQxpwD3wbTEagIgPSQisNgJTvmUX9yYVc6g==" + }, "Microsoft.Extensions.Logging.Console": { "type": "Direct", "requested": "[9.0.4, )", @@ -351,11 +357,6 @@ "Microsoft.Extensions.Primitives": "9.0.4" } }, - "Microsoft.Extensions.FileSystemGlobbing": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "05Lh2ItSk4mzTdDWATW9nEcSybwprN8Tz42Fs5B+jwdXUpauktdAQUI1Am4sUQi2C63E5hvQp8gXvfwfg9mQGQ==" - }, "Microsoft.Extensions.Logging": { "type": "Transitive", "resolved": "9.0.4",