Skip to content

Extends MockResponsePlugin with a command to generate mocks from HTTP responses. Closes #1261 #1262

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
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
99 changes: 99 additions & 0 deletions DevProxy.Abstractions/Models/MockResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,6 +18,103 @@ public object Clone()
var json = JsonSerializer.Serialize(this);
return JsonSerializer.Deserialize<MockResponse>(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<MockResponseHeader>? 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<dynamic>(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
Expand Down
4 changes: 4 additions & 0 deletions DevProxy.Plugins/DevProxy.Plugins.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" Version="9.0.6">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.8.0">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
Expand Down
98 changes: 98 additions & 0 deletions DevProxy.Plugins/Mocking/MockResponsePlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
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;
using System.CommandLine.Invocation;
using System.CommandLine.Parsing;
using System.Globalization;
using System.Net;
Expand Down Expand Up @@ -58,6 +60,8 @@ public class MockResponsePlugin(
private readonly ConcurrentDictionary<string, int> _appliedMocks = [];

private MockResponsesLoader? _loader;
private Argument<IEnumerable<string>>? _httpResponseFilesArgument;
private Option<string>? _httpResponseMocksFileNameOption;

public override string Name => nameof(MockResponsePlugin);

Expand Down Expand Up @@ -86,6 +90,31 @@ 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<IEnumerable<string>>("http-response-files", "Glob pattern to the file(s) containing HTTP responses to create mock responses from")
{
Arity = ArgumentArity.OneOrMore
};
mocksFromHttpResponseCommand.AddArgument(_httpResponseFilesArgument);
_httpResponseMocksFileNameOption = new Option<string>("--mocks-file", "File to save the generated mock responses to")
{
ArgumentHelpName = "mocks file",
Arity = ArgumentArity.ExactlyOne,
IsRequired = true
};
mocksFromHttpResponseCommand.AddOption(_httpResponseMocksFileNameOption);
mocksFromHttpResponseCommand.SetHandler(GenerateMocksFromHttpResponsesAsync);

mocksCommand.AddCommands(new[]
{
mocksFromHttpResponseCommand
}.OrderByName());
return [mocksCommand];
}

public override void OptionsLoaded(OptionsLoadedArgs e)
{
ArgumentNullException.ThrowIfNull(e);
Expand Down Expand Up @@ -389,6 +418,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(InvocationContext context)
{
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 = context.ParseResult.GetValueForOption(_httpResponseMocksFileNameOption);
if (string.IsNullOrEmpty(outputFilePath))
{
Logger.LogError("No output file path provided for mock responses.");
return;
}

var httpResponseFiles = context.ParseResult.GetValueForArgument(_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<MockResponse>();
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")
Expand Down
11 changes: 6 additions & 5 deletions DevProxy.Plugins/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -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, )",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions DevProxy/DevProxy.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.4" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.8.0" />
<PackageReference Include="Microsoft.OpenApi.Readers" Version="1.6.24" />
Expand Down
11 changes: 6 additions & 5 deletions DevProxy/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -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, )",
Expand Down Expand Up @@ -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",
Expand Down