Skip to content

Commit aeaed2e

Browse files
Extends MockResponsePlugin with a command to generate mocks from HTTP responses. Closes #1261 (#1262)
1 parent 7ee55e7 commit aeaed2e

File tree

6 files changed

+215
-10
lines changed

6 files changed

+215
-10
lines changed

DevProxy.Abstractions/Models/MockResponse.cs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

5+
using DevProxy.Abstractions.Utils;
6+
using Microsoft.Extensions.Logging;
57
using System.Text.Json;
68

79
namespace DevProxy.Abstractions.Models;
@@ -16,6 +18,103 @@ public object Clone()
1618
var json = JsonSerializer.Serialize(this);
1719
return JsonSerializer.Deserialize<MockResponse>(json) ?? new MockResponse();
1820
}
21+
22+
public static MockResponse FromHttpResponse(string httpResponse, ILogger logger)
23+
{
24+
logger.LogTrace("{Method} called", nameof(FromHttpResponse));
25+
26+
if (string.IsNullOrWhiteSpace(httpResponse))
27+
{
28+
throw new ArgumentException("HTTP response cannot be null or empty.", nameof(httpResponse));
29+
}
30+
if (!httpResponse.StartsWith("HTTP/", StringComparison.Ordinal))
31+
{
32+
throw new ArgumentException("Invalid HTTP response format. HTTP response must begin with 'HTTP/'", nameof(httpResponse));
33+
}
34+
35+
var lines = httpResponse.Split(["\r\n", "\n"], StringSplitOptions.TrimEntries);
36+
var statusCode = 200;
37+
List<MockResponseHeader>? responseHeaders = null;
38+
dynamic? body = null;
39+
40+
for (var i = 0; i < lines.Length; i++)
41+
{
42+
var line = lines[i];
43+
logger.LogTrace("Processing line {LineNumber}: {LineContent}", i + 1, line);
44+
45+
if (i == 0)
46+
{
47+
// First line is the status line
48+
var parts = line.Split(' ', 3);
49+
if (parts.Length < 2)
50+
{
51+
throw new ArgumentException("Invalid HTTP response format. First line must contain at least HTTP version and status code.", nameof(httpResponse));
52+
}
53+
54+
statusCode = int.TryParse(parts[1], out var _statusCode) ? _statusCode : 200;
55+
}
56+
else if (string.IsNullOrEmpty(line))
57+
{
58+
// empty line indicates the end of headers and the start of the body
59+
var bodyContents = string.Join("\n", lines.Skip(i + 1));
60+
if (string.IsNullOrWhiteSpace(bodyContents))
61+
{
62+
continue;
63+
}
64+
65+
var contentType = responseHeaders?.FirstOrDefault(h => h.Name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase))?.Value;
66+
if (contentType is not null && contentType.Contains("application/json", StringComparison.OrdinalIgnoreCase))
67+
{
68+
try
69+
{
70+
body = JsonSerializer.Deserialize<dynamic>(bodyContents, ProxyUtils.JsonSerializerOptions);
71+
}
72+
catch (JsonException ex)
73+
{
74+
logger.LogError(ex, "Failed to deserialize JSON body from HTTP response");
75+
body = bodyContents;
76+
}
77+
}
78+
else
79+
{
80+
body = bodyContents;
81+
}
82+
83+
break;
84+
}
85+
else
86+
{
87+
// Headers
88+
var headerParts = line.Split(':', 2);
89+
if (headerParts.Length < 2)
90+
{
91+
logger.LogError($"Invalid HTTP response header format");
92+
continue;
93+
}
94+
95+
responseHeaders ??= [];
96+
responseHeaders.Add(new(headerParts[0].Trim(), headerParts[1].Trim()));
97+
}
98+
}
99+
100+
var mockResponse = new MockResponse
101+
{
102+
Request = new()
103+
{
104+
Url = "*"
105+
},
106+
Response = new()
107+
{
108+
StatusCode = statusCode,
109+
Headers = responseHeaders,
110+
Body = body
111+
}
112+
};
113+
114+
logger.LogTrace("Left {Method}", nameof(FromHttpResponse));
115+
116+
return mockResponse;
117+
}
19118
}
20119

21120
public class MockResponseRequest

DevProxy.Plugins/DevProxy.Plugins.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
<Private>false</Private>
2929
<ExcludeAssets>runtime</ExcludeAssets>
3030
</PackageReference>
31+
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" Version="9.0.6">
32+
<Private>false</Private>
33+
<ExcludeAssets>runtime</ExcludeAssets>
34+
</PackageReference>
3135
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.8.0">
3236
<Private>false</Private>
3337
<ExcludeAssets>runtime</ExcludeAssets>

DevProxy.Plugins/Mocking/MockResponsePlugin.cs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using DevProxy.Plugins.Models;
1111
using Microsoft.Extensions.Configuration;
1212
using Microsoft.Extensions.DependencyInjection;
13+
using Microsoft.Extensions.FileSystemGlobbing;
1314
using Microsoft.Extensions.Logging;
1415
using System.Collections.Concurrent;
1516
using System.CommandLine;
@@ -60,6 +61,8 @@ public class MockResponsePlugin(
6061
private readonly ConcurrentDictionary<string, int> _appliedMocks = [];
6162

6263
private MockResponsesLoader? _loader;
64+
private Argument<IEnumerable<string>>? _httpResponseFilesArgument;
65+
private Option<string>? _httpResponseMocksFileNameOption;
6366

6467
public override string Name => nameof(MockResponsePlugin);
6568

@@ -89,6 +92,33 @@ public override Option[] GetOptions()
8992
return [_noMocks, _mocksFile];
9093
}
9194

95+
public override Command[] GetCommands()
96+
{
97+
var mocksCommand = new Command("mocks", "Manage mock responses");
98+
var mocksFromHttpResponseCommand = new Command("from-http-responses", "Create a mock response from HTTP responses");
99+
_httpResponseFilesArgument = new Argument<IEnumerable<string>>("http-response-files")
100+
{
101+
Arity = ArgumentArity.OneOrMore,
102+
Description = "Glob pattern to the file(s) containing HTTP responses to create mock responses from",
103+
};
104+
mocksFromHttpResponseCommand.Add(_httpResponseFilesArgument);
105+
_httpResponseMocksFileNameOption = new Option<string>("--mocks-file")
106+
{
107+
HelpName = "mocks file",
108+
Arity = ArgumentArity.ExactlyOne,
109+
Description = "File to save the generated mock responses to",
110+
Required = true
111+
};
112+
mocksFromHttpResponseCommand.Add(_httpResponseMocksFileNameOption);
113+
mocksFromHttpResponseCommand.SetAction(GenerateMocksFromHttpResponsesAsync);
114+
115+
mocksCommand.AddCommands(new[]
116+
{
117+
mocksFromHttpResponseCommand
118+
}.OrderByName());
119+
return [mocksCommand];
120+
}
121+
92122
public override void OptionsLoaded(OptionsLoadedArgs e)
93123
{
94124
ArgumentNullException.ThrowIfNull(e);
@@ -392,6 +422,75 @@ private void ProcessMockResponseInternal(ProxyRequestArgs e, MockResponse matchi
392422
Logger.LogRequest($"{matchingResponse.Response?.StatusCode ?? 200} {matchingResponse.Request?.Url}", MessageType.Mocked, new(e.Session));
393423
}
394424

425+
private async Task GenerateMocksFromHttpResponsesAsync(ParseResult parseResult)
426+
{
427+
Logger.LogTrace("{Method} called", nameof(GenerateMocksFromHttpResponsesAsync));
428+
429+
if (_httpResponseFilesArgument is null)
430+
{
431+
throw new InvalidOperationException("HTTP response files argument is not initialized.");
432+
}
433+
if (_httpResponseMocksFileNameOption is null)
434+
{
435+
throw new InvalidOperationException("HTTP response mocks file name option is not initialized.");
436+
}
437+
438+
var outputFilePath = parseResult.GetValue(_httpResponseMocksFileNameOption);
439+
if (string.IsNullOrEmpty(outputFilePath))
440+
{
441+
Logger.LogError("No output file path provided for mock responses.");
442+
return;
443+
}
444+
445+
var httpResponseFiles = parseResult.GetValue(_httpResponseFilesArgument);
446+
if (httpResponseFiles is null || !httpResponseFiles.Any())
447+
{
448+
Logger.LogError("No HTTP response files provided.");
449+
return;
450+
}
451+
452+
var matcher = new Matcher();
453+
matcher.AddIncludePatterns(httpResponseFiles);
454+
455+
var matchingFiles = matcher.GetResultsInFullPath(".");
456+
if (!matchingFiles.Any())
457+
{
458+
Logger.LogError("No matching HTTP response files found.");
459+
return;
460+
}
461+
462+
Logger.LogInformation("Found {FileCount} matching HTTP response files", matchingFiles.Count());
463+
Logger.LogDebug("Matching files: {Files}", string.Join(", ", matchingFiles));
464+
465+
var mockResponses = new List<MockResponse>();
466+
foreach (var file in matchingFiles)
467+
{
468+
Logger.LogInformation("Processing file: {File}", Path.GetRelativePath(".", file));
469+
try
470+
{
471+
mockResponses.Add(MockResponse.FromHttpResponse(await File.ReadAllTextAsync(file), Logger));
472+
}
473+
catch (Exception ex)
474+
{
475+
Logger.LogError(ex, "Error processing file {File}", file);
476+
continue;
477+
}
478+
}
479+
480+
var mocksFile = new MockResponseConfiguration
481+
{
482+
Mocks = mockResponses
483+
};
484+
await File.WriteAllTextAsync(
485+
outputFilePath,
486+
JsonSerializer.Serialize(mocksFile, ProxyUtils.JsonSerializerOptions)
487+
);
488+
489+
Logger.LogInformation("Generated mock responses saved to {OutputFile}", outputFilePath);
490+
491+
Logger.LogTrace("Left {Method}", nameof(GenerateMocksFromHttpResponsesAsync));
492+
}
493+
395494
private static bool HasMatchingBody(MockResponse mockResponse, Request request)
396495
{
397496
if (request.Method == "GET")

DevProxy.Plugins/packages.lock.json

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@
4141
"Microsoft.Extensions.Primitives": "9.0.4"
4242
}
4343
},
44+
"Microsoft.Extensions.FileSystemGlobbing": {
45+
"type": "Direct",
46+
"requested": "[9.0.6, )",
47+
"resolved": "9.0.6",
48+
"contentHash": "1HJCAbwukNEoYbHgHbKHmenU0V/0huw8+i7Qtf5rLUG1E+3kEwRJQxpwD3wbTEagIgPSQisNgJTvmUX9yYVc6g=="
49+
},
4450
"Microsoft.IdentityModel.Protocols.OpenIdConnect": {
4551
"type": "Direct",
4652
"requested": "[8.8.0, )",
@@ -304,11 +310,6 @@
304310
"Microsoft.Extensions.Primitives": "9.0.4"
305311
}
306312
},
307-
"Microsoft.Extensions.FileSystemGlobbing": {
308-
"type": "Transitive",
309-
"resolved": "9.0.4",
310-
"contentHash": "05Lh2ItSk4mzTdDWATW9nEcSybwprN8Tz42Fs5B+jwdXUpauktdAQUI1Am4sUQi2C63E5hvQp8gXvfwfg9mQGQ=="
311-
},
312313
"Microsoft.Extensions.Logging": {
313314
"type": "Transitive",
314315
"resolved": "9.0.4",

DevProxy/DevProxy.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.4" />
3838
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.4" />
3939
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.4" />
40+
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" Version="9.0.6" />
4041
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.4" />
4142
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.8.0" />
4243
<PackageReference Include="Microsoft.OpenApi.Readers" Version="1.6.24" />

DevProxy/packages.lock.json

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@
6262
"Microsoft.Extensions.FileProviders.Abstractions": "9.0.4"
6363
}
6464
},
65+
"Microsoft.Extensions.FileSystemGlobbing": {
66+
"type": "Direct",
67+
"requested": "[9.0.6, )",
68+
"resolved": "9.0.6",
69+
"contentHash": "1HJCAbwukNEoYbHgHbKHmenU0V/0huw8+i7Qtf5rLUG1E+3kEwRJQxpwD3wbTEagIgPSQisNgJTvmUX9yYVc6g=="
70+
},
6571
"Microsoft.Extensions.Logging.Console": {
6672
"type": "Direct",
6773
"requested": "[9.0.4, )",
@@ -351,11 +357,6 @@
351357
"Microsoft.Extensions.Primitives": "9.0.4"
352358
}
353359
},
354-
"Microsoft.Extensions.FileSystemGlobbing": {
355-
"type": "Transitive",
356-
"resolved": "9.0.4",
357-
"contentHash": "05Lh2ItSk4mzTdDWATW9nEcSybwprN8Tz42Fs5B+jwdXUpauktdAQUI1Am4sUQi2C63E5hvQp8gXvfwfg9mQGQ=="
358-
},
359360
"Microsoft.Extensions.Logging": {
360361
"type": "Transitive",
361362
"resolved": "9.0.4",

0 commit comments

Comments
 (0)