Skip to content

Commit cbbb256

Browse files
Adds the GraphMockResponsePlugin. Closes #307 (#328)
1 parent c5c8227 commit cbbb256

File tree

7 files changed

+188
-8
lines changed

7 files changed

+188
-8
lines changed

m365-developer-proxy-abstractions/GraphBatchResponsePayload.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public class GraphBatchResponsePayloadResponse {
1616
[JsonPropertyName("status")]
1717
public int Status { get; set; } = 200;
1818
[JsonPropertyName("body")]
19-
public GraphBatchResponsePayloadResponseBody? Body { get; set; }
19+
public dynamic? Body { get; set; }
2020
[JsonPropertyName("headers")]
2121
public Dictionary<string, string>? Headers { get; set; }
2222
}

m365-developer-proxy-abstractions/ProxyUtils.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ public static bool IsGraphBetaUrl(Uri uri) =>
7373
/// <param name="requestDate">string representation of the date and time the request was made</param>
7474
/// <returns>IList<HttpHeader> with defaults consistent with Microsoft Graph. Automatically adds CORS headers when the Origin header is present</returns>
7575
public static IList<HttpHeader> BuildGraphResponseHeaders(Request request, string requestId, string requestDate) {
76+
if (!IsGraphRequest(request)) {
77+
return new List<HttpHeader>();
78+
}
79+
7680
var headers = new List<HttpHeader>
7781
{
7882
new HttpHeader("Cache-Control", "no-store"),
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Net;
5+
using System.Text.Json;
6+
using System.Text.RegularExpressions;
7+
using Microsoft365.DeveloperProxy.Abstractions;
8+
9+
namespace Microsoft365.DeveloperProxy.Plugins.MockResponses;
10+
11+
public class GraphMockResponsePlugin : MockResponsePlugin
12+
{
13+
public override string Name => nameof(GraphMockResponsePlugin);
14+
15+
protected override async Task OnRequest(object? sender, ProxyRequestArgs e)
16+
{
17+
if (!ProxyUtils.IsGraphBatchUrl(e.Session.HttpClient.Request.RequestUri))
18+
{
19+
// not a batch request, use the basic mock functionality
20+
await base.OnRequest(sender, e);
21+
return;
22+
}
23+
24+
var batch = JsonSerializer.Deserialize<GraphBatchRequestPayload>(e.Session.HttpClient.Request.BodyString);
25+
if (batch == null)
26+
{
27+
await base.OnRequest(sender, e);
28+
return;
29+
}
30+
31+
var responses = new List<GraphBatchResponsePayloadResponse>();
32+
foreach (var request in batch.Requests)
33+
{
34+
GraphBatchResponsePayloadResponse? response = null;
35+
var requestId = Guid.NewGuid().ToString();
36+
var requestDate = DateTime.Now.ToString();
37+
var headers = ProxyUtils
38+
.BuildGraphResponseHeaders(e.Session.HttpClient.Request, requestId, requestDate)
39+
.ToDictionary(h => h.Name, h => h.Value);
40+
41+
var mockResponse = GetMatchingMockResponse(request, e.Session.HttpClient.Request.RequestUri);
42+
if (mockResponse == null)
43+
{
44+
response = new GraphBatchResponsePayloadResponse
45+
{
46+
Id = request.Id,
47+
Status = (int)HttpStatusCode.BadGateway,
48+
Headers = headers,
49+
Body = new GraphBatchResponsePayloadResponseBody
50+
{
51+
Error = new GraphBatchResponsePayloadResponseBodyError
52+
{
53+
Code = "BadGateway",
54+
Message = "No mock response found for this request"
55+
}
56+
}
57+
};
58+
59+
_logger?.LogRequest(new[] { $"502 {request.Url}" }, MessageType.Mocked, new LoggingContext(e.Session));
60+
}
61+
else
62+
{
63+
dynamic? body = null;
64+
var statusCode = HttpStatusCode.OK;
65+
if (mockResponse.ResponseCode is not null)
66+
{
67+
statusCode = (HttpStatusCode)mockResponse.ResponseCode;
68+
}
69+
70+
if (mockResponse.ResponseHeaders is not null)
71+
{
72+
foreach (var key in mockResponse.ResponseHeaders.Keys)
73+
{
74+
headers[key] = mockResponse.ResponseHeaders[key];
75+
}
76+
}
77+
// default the content type to application/json unless set in the mock response
78+
if (!headers.Any(h => h.Key.Equals("content-type", StringComparison.OrdinalIgnoreCase)))
79+
{
80+
headers.Add("content-type", "application/json");
81+
}
82+
83+
if (mockResponse.ResponseBody is not null)
84+
{
85+
var bodyString = JsonSerializer.Serialize(mockResponse.ResponseBody) as string;
86+
// we get a JSON string so need to start with the opening quote
87+
if (bodyString?.StartsWith("\"@") ?? false)
88+
{
89+
// we've got a mock body starting with @-token which means we're sending
90+
// a response from a file on disk
91+
// if we can read the file, we can immediately send the response and
92+
// skip the rest of the logic in this method
93+
// remove the surrounding quotes and the @-token
94+
var filePath = Path.Combine(Path.GetDirectoryName(_configuration.MocksFile) ?? "", ProxyUtils.ReplacePathTokens(bodyString.Trim('"').Substring(1)));
95+
if (!File.Exists(filePath))
96+
{
97+
_logger?.LogError($"File {filePath} not found. Serving file path in the mock response");
98+
body = bodyString;
99+
}
100+
else
101+
{
102+
var bodyBytes = File.ReadAllBytes(filePath);
103+
body = Convert.ToBase64String(bodyBytes);
104+
}
105+
}
106+
else
107+
{
108+
body = mockResponse.ResponseBody;
109+
}
110+
}
111+
response = new GraphBatchResponsePayloadResponse
112+
{
113+
Id = request.Id,
114+
Status = (int)statusCode,
115+
Headers = headers,
116+
Body = body
117+
};
118+
119+
_logger?.LogRequest(new[] { $"{mockResponse.ResponseCode ?? 200} {mockResponse.Url}" }, MessageType.Mocked, new LoggingContext(e.Session));
120+
}
121+
122+
responses.Add(response);
123+
}
124+
125+
var batchRequestId = Guid.NewGuid().ToString();
126+
var batchRequestDate = DateTime.Now.ToString();
127+
var batchHeaders = ProxyUtils.BuildGraphResponseHeaders(e.Session.HttpClient.Request, batchRequestId, batchRequestDate);
128+
var batchResponse = new GraphBatchResponsePayload
129+
{
130+
Responses = responses.ToArray()
131+
};
132+
e.Session.GenericResponse(JsonSerializer.Serialize(batchResponse), HttpStatusCode.OK, batchHeaders);
133+
}
134+
135+
protected MockResponse? GetMatchingMockResponse(GraphBatchRequestPayloadRequest request, Uri batchRequestUri)
136+
{
137+
if (_configuration.NoMocks ||
138+
_configuration.Responses is null ||
139+
!_configuration.Responses.Any())
140+
{
141+
return null;
142+
}
143+
144+
var mockResponse = _configuration.Responses.FirstOrDefault(mockResponse =>
145+
{
146+
if (mockResponse.Method != request.Method) return false;
147+
// URLs in batch are relative to Graph version number so we need
148+
// to make them absolute using the batch request URL
149+
var absoluteRequestFromBatchUrl = ProxyUtils
150+
.GetAbsoluteRequestUrlFromBatch(batchRequestUri, request.Url)
151+
.ToString();
152+
if (mockResponse.Url == absoluteRequestFromBatchUrl)
153+
{
154+
return true;
155+
}
156+
157+
// check if the URL contains a wildcard
158+
// if it doesn't, it's not a match for the current request for sure
159+
if (!mockResponse.Url.Contains('*'))
160+
{
161+
return false;
162+
}
163+
164+
//turn mock URL with wildcard into a regex and match against the request URL
165+
var mockResponseUrlRegex = Regex.Escape(mockResponse.Url).Replace("\\*", ".*");
166+
return Regex.IsMatch(absoluteRequestFromBatchUrl, $"^{mockResponseUrlRegex}$");
167+
});
168+
return mockResponse;
169+
}
170+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
using System.Text.Json.Serialization;
55

6-
namespace Microsoft365.DeveloperProxy.Plugins.MocksResponses;
6+
namespace Microsoft365.DeveloperProxy.Plugins.MockResponses;
77

88
public class MockResponse {
99
[JsonPropertyName("url")]

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
using Titanium.Web.Proxy.Http;
1414
using Titanium.Web.Proxy.Models;
1515

16-
namespace Microsoft365.DeveloperProxy.Plugins.MocksResponses;
16+
namespace Microsoft365.DeveloperProxy.Plugins.MockResponses;
1717

18-
internal class MockResponseConfiguration {
18+
public class MockResponseConfiguration {
1919
public bool NoMocks { get; set; } = false;
2020
public string MocksFile { get; set; } = "responses.json";
2121

@@ -24,7 +24,7 @@ internal class MockResponseConfiguration {
2424
}
2525

2626
public class MockResponsePlugin : BaseProxyPlugin {
27-
private MockResponseConfiguration _configuration = new();
27+
protected MockResponseConfiguration _configuration = new();
2828
private MockResponsesLoader? _loader = null;
2929
private readonly Option<bool?> _noMocks;
3030
private readonly Option<string?> _mocksFile;
@@ -89,7 +89,7 @@ private void OnOptionsLoaded(object? sender, OptionsLoadedArgs e) {
8989
_loader?.InitResponsesWatcher();
9090
}
9191

92-
private async Task OnRequest(object? sender, ProxyRequestArgs e) {
92+
protected virtual async Task OnRequest(object? sender, ProxyRequestArgs e) {
9393
Request request = e.Session.HttpClient.Request;
9494
ResponseState state = e.ResponseState;
9595
if (!_configuration.NoMocks && _urlsToWatch is not null && e.ShouldExecute(_urlsToWatch)) {
@@ -114,7 +114,7 @@ _configuration.Responses is null ||
114114
return true;
115115
}
116116

117-
//check if the URL contains a wildcard
117+
// check if the URL contains a wildcard
118118
// if it doesn't, it's not a match for the current request for sure
119119
if (!mockResponse.Url.Contains('*')) {
120120
return false;

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
using Microsoft365.DeveloperProxy.Abstractions;
55
using System.Text.Json;
66

7-
namespace Microsoft365.DeveloperProxy.Plugins.MocksResponses;
7+
namespace Microsoft365.DeveloperProxy.Plugins.MockResponses;
88

99
internal class MockResponsesLoader : IDisposable {
1010
private readonly ILogger _logger;

m365-developer-proxy/m365proxyrc.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@
8686
},
8787
{
8888
"name": "MockResponsePlugin",
89+
"enabled": false,
90+
"pluginPath": "plugins\\m365-developer-proxy-plugins.dll",
91+
"configSection": "mocksPlugin"
92+
},
93+
{
94+
"name": "GraphMockResponsePlugin",
8995
"enabled": true,
9096
"pluginPath": "plugins\\m365-developer-proxy-plugins.dll",
9197
"configSection": "mocksPlugin"

0 commit comments

Comments
 (0)