Skip to content

Commit 2a3d548

Browse files
Extends MockResponsePlugin with a command to generate mocks from HTTP responses. Closes #1261
1 parent 1c4ddf4 commit 2a3d548

File tree

6 files changed

+214
-10
lines changed

6 files changed

+214
-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: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
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;
17+
using System.CommandLine.Invocation;
1618
using System.CommandLine.Parsing;
1719
using System.Globalization;
1820
using System.Net;
@@ -58,6 +60,8 @@ public class MockResponsePlugin(
5860
private readonly ConcurrentDictionary<string, int> _appliedMocks = [];
5961

6062
private MockResponsesLoader? _loader;
63+
private Argument<IEnumerable<string>>? _httpResponseFilesArgument;
64+
private Option<string>? _httpResponseMocksFileNameOption;
6165

6266
public override string Name => nameof(MockResponsePlugin);
6367

@@ -86,6 +90,31 @@ public override Option[] GetOptions()
8690
return [_noMocks, _mocksFile];
8791
}
8892

93+
public override Command[] GetCommands()
94+
{
95+
var mocksCommand = new Command("mocks", "Manage mock responses");
96+
var mocksFromHttpResponseCommand = new Command("from-http-responses", "Create a mock response from HTTP responses");
97+
_httpResponseFilesArgument = new Argument<IEnumerable<string>>("http-response-files", "Glob pattern to the file(s) containing HTTP responses to create mock responses from")
98+
{
99+
Arity = ArgumentArity.OneOrMore
100+
};
101+
mocksFromHttpResponseCommand.AddArgument(_httpResponseFilesArgument);
102+
_httpResponseMocksFileNameOption = new Option<string>("--mocks-file", "File to save the generated mock responses to")
103+
{
104+
ArgumentHelpName = "mocks file",
105+
Arity = ArgumentArity.ExactlyOne,
106+
IsRequired = true
107+
};
108+
mocksFromHttpResponseCommand.AddOption(_httpResponseMocksFileNameOption);
109+
mocksFromHttpResponseCommand.SetHandler(GenerateMocksFromHttpResponsesAsync);
110+
111+
mocksCommand.AddCommands(new[]
112+
{
113+
mocksFromHttpResponseCommand
114+
}.OrderByName());
115+
return [mocksCommand];
116+
}
117+
89118
public override void OptionsLoaded(OptionsLoadedArgs e)
90119
{
91120
ArgumentNullException.ThrowIfNull(e);
@@ -389,6 +418,75 @@ private void ProcessMockResponseInternal(ProxyRequestArgs e, MockResponse matchi
389418
Logger.LogRequest($"{matchingResponse.Response?.StatusCode ?? 200} {matchingResponse.Request?.Url}", MessageType.Mocked, new(e.Session));
390419
}
391420

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