Skip to content

Commit acfc289

Browse files
feat: add mocks generator plugin. Closes #305 (#326)
* Adds mocks generator plugin. Closes #305 * Fixes namespace typo
1 parent b5b2dbe commit acfc289

File tree

6 files changed

+188
-18
lines changed

6 files changed

+188
-18
lines changed

m365-developer-proxy-abstractions/ILogger.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ public enum MessageType {
1313
Tip,
1414
Failed,
1515
Chaos,
16-
Mocked
16+
Mocked,
17+
InterceptedResponse
1718
}
1819

1920
public class LoggingContext {

m365-developer-proxy-plugins/MockResponses/MockResponsePlugin.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
namespace Microsoft365.DeveloperProxy.Plugins.MockResponses;
1717

1818
public class MockResponseConfiguration {
19+
[JsonIgnore]
1920
public bool NoMocks { get; set; } = false;
21+
[JsonIgnore]
2022
public string MocksFile { get; set; } = "responses.json";
2123
public bool BlockUnmockedRequests { get; set; } = false;
2224

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Text.Json;
5+
using Microsoft.Extensions.Configuration;
6+
using Microsoft365.DeveloperProxy.Abstractions;
7+
using Microsoft365.DeveloperProxy.Plugins.MockResponses;
8+
using Titanium.Web.Proxy.EventArguments;
9+
10+
namespace Microsoft365.DeveloperProxy.Plugins.RequestLogs;
11+
12+
public class MockGeneratorPlugin : BaseProxyPlugin
13+
{
14+
public override string Name => nameof(MockGeneratorPlugin);
15+
16+
public override void Register(IPluginEvents pluginEvents,
17+
IProxyContext context,
18+
ISet<UrlToWatch> urlsToWatch,
19+
IConfigurationSection? configSection = null)
20+
{
21+
base.Register(pluginEvents, context, urlsToWatch, configSection);
22+
23+
pluginEvents.AfterRecordingStop += AfterRecordingStop;
24+
}
25+
26+
private void AfterRecordingStop(object? sender, RecordingArgs e)
27+
{
28+
_logger?.LogInfo("Creating mocks from recorded requests...");
29+
30+
if (!e.RequestLogs.Any())
31+
{
32+
_logger?.LogDebug("No requests to process");
33+
return;
34+
}
35+
36+
var methodAndUrlComparer = new MethodAndUrlComparer();
37+
var mocks = new List<MockResponse>();
38+
39+
foreach (var request in e.RequestLogs)
40+
{
41+
if (request.MessageType != MessageType.InterceptedResponse ||
42+
request.Context is null ||
43+
request.Context.Session is null)
44+
{
45+
continue;
46+
}
47+
48+
var methodAndUrlString = request.Message.First();
49+
_logger?.LogDebug($"Processing request {methodAndUrlString}...");
50+
51+
var methodAndUrl = GetMethodAndUrl(methodAndUrlString);
52+
var response = request.Context.Session.HttpClient.Response;
53+
54+
var mock = new MockResponse
55+
{
56+
Method = methodAndUrl.Item1,
57+
Url = methodAndUrl.Item2,
58+
ResponseCode = response.StatusCode,
59+
ResponseHeaders = response.Headers
60+
.Select(h => new KeyValuePair<string, string>(h.Name, h.Value))
61+
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
62+
ResponseBody = GetResponseBody(request.Context.Session).Result
63+
};
64+
// skip mock if it's 200 but has no body
65+
if (mock.ResponseCode == 200 && mock.ResponseBody is null)
66+
{
67+
_logger?.LogDebug("Skipping mock with 200 response code and no body");
68+
continue;
69+
}
70+
71+
mocks.Add(mock);
72+
_logger?.LogDebug($"Added mock for {mock.Method} {mock.Url}");
73+
}
74+
75+
_logger?.LogDebug($"Sorting mocks...");
76+
// sort mocks descending by url length so that most specific mocks are first
77+
mocks.Sort((a, b) => b.Url.CompareTo(a.Url));
78+
79+
var mocksFile = new MockResponseConfiguration { Responses = mocks };
80+
81+
_logger?.LogDebug($"Serializing mocks...");
82+
var mocksFileJson = JsonSerializer.Serialize(mocksFile, new JsonSerializerOptions { WriteIndented = true });
83+
var fileName = $"mocks-{DateTime.Now.ToString("yyyyMMddHHmmss")}.json";
84+
85+
_logger?.LogDebug($"Writing mocks to {fileName}...");
86+
File.WriteAllText(fileName, mocksFileJson);
87+
88+
_logger?.LogInfo($"Created mock file {fileName} with {mocks.Count} mocks");
89+
}
90+
91+
/// <summary>
92+
/// Returns the body of the response. For binary responses,
93+
/// saves the binary response as a file on disk and returns @filename
94+
/// </summary>
95+
/// <param name="session">Request session</param>
96+
/// <returns>Response body or @filename for binary responses</returns>
97+
private async Task<dynamic?> GetResponseBody(SessionEventArgs session)
98+
{
99+
_logger?.LogDebug("Getting response body...");
100+
101+
var response = session.HttpClient.Response;
102+
if (response.ContentType is null || !response.HasBody)
103+
{
104+
_logger?.LogDebug("Response has no content-type set or has no body. Skipping");
105+
return null;
106+
}
107+
108+
if (response.ContentType.Contains("application/json"))
109+
{
110+
_logger?.LogDebug("Response is JSON");
111+
112+
try
113+
{
114+
_logger?.LogDebug("Reading response body as string...");
115+
var body = response.IsBodyRead ? response.BodyString : await session.GetResponseBodyAsString();
116+
_logger?.LogDebug($"Body: {body}");
117+
_logger?.LogDebug("Deserializing response body...");
118+
return JsonSerializer.Deserialize<dynamic>(body);
119+
}
120+
catch (Exception ex)
121+
{
122+
_logger?.LogError($"Error reading response body: {ex.Message}");
123+
return null;
124+
}
125+
}
126+
127+
_logger?.LogDebug("Response is binary");
128+
// assume body is binary
129+
try
130+
{
131+
var filename = $"response-{DateTime.Now.ToString("yyyyMMddHHmmss")}.bin";
132+
_logger?.LogDebug("Reading response body as bytes...");
133+
var body = await session.GetResponseBody();
134+
_logger?.LogDebug($"Writing response body to {filename}...");
135+
File.WriteAllBytes(filename, body);
136+
return $"@{filename}";
137+
}
138+
catch (Exception ex)
139+
{
140+
_logger?.LogError($"Error reading response body: {ex.Message}");
141+
return null;
142+
}
143+
}
144+
145+
private Tuple<string, string> GetMethodAndUrl(string message)
146+
{
147+
var info = message.Split(" ");
148+
if (info.Length > 2)
149+
{
150+
info = new[] { info[0], String.Join(" ", info.Skip(1)) };
151+
}
152+
return new Tuple<string, string>(info[0], info[1]);
153+
}
154+
}

m365-developer-proxy/ConsoleLogger.cs

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -70,24 +70,27 @@ public void LogDebug(string message) {
7070
public void LogRequest(string[] message, MessageType messageType, LoggingContext? context = null) {
7171
var messageLines = new List<string>(message);
7272

73-
// add request context information to the message for messages
74-
// that are not intercepted requests and have a context
75-
if (messageType != MessageType.InterceptedRequest &&
76-
context is not null) {
77-
messageLines.Add($"{context.Session.HttpClient.Request.Method} {context.Session.HttpClient.Request.Url}");
78-
}
73+
// don't log intercepted response to console
74+
if (messageType != MessageType.InterceptedResponse) {
75+
// add request context information to the message for messages
76+
// that are not intercepted requests and have a context
77+
if (messageType != MessageType.InterceptedRequest &&
78+
context is not null) {
79+
messageLines.Add($"{context.Session.HttpClient.Request.Method} {context.Session.HttpClient.Request.Url}");
80+
}
7981

80-
lock (ConsoleLock) {
81-
switch (_labelMode) {
82-
case LabelMode.Text:
83-
WriteBoxedWithInvertedLabels(messageLines.ToArray(), messageType);
84-
break;
85-
case LabelMode.Icon:
86-
WriteBoxedWithAsciiIcons(messageLines.ToArray(), messageType);
87-
break;
88-
case LabelMode.NerdFont:
89-
WriteBoxedWithNerdFontIcons(messageLines.ToArray(), messageType);
90-
break;
82+
lock (ConsoleLock) {
83+
switch (_labelMode) {
84+
case LabelMode.Text:
85+
WriteBoxedWithInvertedLabels(messageLines.ToArray(), messageType);
86+
break;
87+
case LabelMode.Icon:
88+
WriteBoxedWithAsciiIcons(messageLines.ToArray(), messageType);
89+
break;
90+
case LabelMode.NerdFont:
91+
WriteBoxedWithNerdFontIcons(messageLines.ToArray(), messageType);
92+
break;
93+
}
9194
}
9295
}
9396

m365-developer-proxy/ProxyEngine.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,12 +380,17 @@ private async Task HandleRequest(SessionEventArgs e) {
380380
async Task OnBeforeResponse(object sender, SessionEventArgs e) {
381381
// read response headers
382382
if (IsProxiedHost(e.HttpClient.Request.RequestUri.Host)) {
383+
// necessary to make the response body available to plugins
384+
e.HttpClient.Response.KeepBody = true;
385+
await e.GetResponseBody();
386+
383387
await _pluginEvents.RaiseProxyBeforeResponse(new ProxyResponseArgs(e, _throttledRequests, new ResponseState()));
384388
}
385389
}
386390
async Task OnAfterResponse(object sender, SessionEventArgs e) {
387391
// read response headers
388392
if (IsProxiedHost(e.HttpClient.Request.RequestUri.Host)) {
393+
_logger.LogRequest(new[] { $"{e.HttpClient.Request.Method} {e.HttpClient.Request.Url}" }, MessageType.InterceptedResponse, new LoggingContext(e));
389394
_pluginEvents.RaiseProxyAfterResponse(new ProxyResponseArgs(e, _throttledRequests, new ResponseState()));
390395
}
391396
}

m365-developer-proxy/m365proxyrc.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@
118118
"name": "MinimalPermissionsGuidancePlugin",
119119
"enabled": false,
120120
"pluginPath": "plugins\\m365-developer-proxy-plugins.dll"
121+
},
122+
{
123+
"name": "MockGeneratorPlugin",
124+
"enabled": false,
125+
"pluginPath": "plugins\\m365-developer-proxy-plugins.dll"
121126
}
122127
],
123128
"urlsToWatch": [

0 commit comments

Comments
 (0)