Skip to content

Commit bfc5d15

Browse files
Adds the HarGeneratorPlugin. Closes #1412 (#1421)
Co-authored-by: Garry Trinder <[email protected]>
1 parent 3950d07 commit bfc5d15

File tree

6 files changed

+330
-11
lines changed

6 files changed

+330
-11
lines changed
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using DevProxy.Abstractions.Plugins;
6+
using DevProxy.Abstractions.Proxy;
7+
using DevProxy.Abstractions.Utils;
8+
using DevProxy.Plugins.Models;
9+
using Microsoft.Extensions.Configuration;
10+
using Microsoft.Extensions.Logging;
11+
using System.Diagnostics;
12+
using System.Text.Json;
13+
using System.Web;
14+
15+
namespace DevProxy.Plugins.Generation;
16+
17+
public sealed class HarGeneratorPluginConfiguration
18+
{
19+
public bool IncludeSensitiveInformation { get; set; }
20+
public bool IncludeResponse { get; set; }
21+
}
22+
23+
public sealed class HarGeneratorPlugin(
24+
HttpClient httpClient,
25+
ILogger<HarGeneratorPlugin> logger,
26+
ISet<UrlToWatch> urlsToWatch,
27+
IProxyConfiguration proxyConfiguration,
28+
IConfigurationSection pluginConfigurationSection) :
29+
BaseReportingPlugin<HarGeneratorPluginConfiguration>(
30+
httpClient,
31+
logger,
32+
urlsToWatch,
33+
proxyConfiguration,
34+
pluginConfigurationSection)
35+
{
36+
public override string Name => nameof(HarGeneratorPlugin);
37+
38+
public override async Task AfterRecordingStopAsync(RecordingArgs e, CancellationToken cancellationToken)
39+
{
40+
Logger.LogTrace("{Method} called", nameof(AfterRecordingStopAsync));
41+
42+
ArgumentNullException.ThrowIfNull(e);
43+
44+
if (!e.RequestLogs.Any())
45+
{
46+
Logger.LogDebug("No requests to process");
47+
return;
48+
}
49+
50+
Logger.LogInformation("Creating HAR file from recorded requests...");
51+
52+
var harFile = new HarFile
53+
{
54+
Log = new HarLog
55+
{
56+
Creator = new HarCreator
57+
{
58+
Name = "DevProxy",
59+
Version = ProxyUtils.ProductVersion
60+
},
61+
Entries = [.. e.RequestLogs.Where(r =>
62+
r.MessageType == MessageType.InterceptedResponse &&
63+
r is not null &&
64+
r.Context is not null &&
65+
r.Context.Session is not null &&
66+
ProxyUtils.MatchesUrlToWatch(UrlsToWatch, r.Context.Session.HttpClient.Request.RequestUri.AbsoluteUri)).Select(CreateHarEntry)]
67+
}
68+
};
69+
70+
Logger.LogDebug("Serializing HAR file...");
71+
var harFileJson = JsonSerializer.Serialize(harFile, ProxyUtils.JsonSerializerOptions);
72+
var fileName = $"devproxy-{DateTime.Now:yyyyMMddHHmmss}.har";
73+
74+
Logger.LogDebug("Writing HAR file to {FileName}...", fileName);
75+
await File.WriteAllTextAsync(fileName, harFileJson, cancellationToken);
76+
77+
Logger.LogInformation("Created HAR file {FileName}", fileName);
78+
79+
StoreReport(fileName, e);
80+
81+
Logger.LogTrace("Left {Name}", nameof(AfterRecordingStopAsync));
82+
}
83+
84+
private string GetHeaderValue(string headerName, string originalValue)
85+
{
86+
if (!Configuration.IncludeSensitiveInformation &&
87+
Http.SensitiveHeaders.Contains(headerName, StringComparer.OrdinalIgnoreCase))
88+
{
89+
return "REDACTED";
90+
}
91+
return originalValue;
92+
}
93+
94+
private HarEntry CreateHarEntry(RequestLog log)
95+
{
96+
Debug.Assert(log is not null);
97+
Debug.Assert(log.Context is not null);
98+
99+
var request = log.Context.Session.HttpClient.Request;
100+
var response = log.Context.Session.HttpClient.Response;
101+
var currentTime = DateTime.UtcNow;
102+
103+
var entry = new HarEntry
104+
{
105+
StartedDateTime = currentTime.ToString("o"),
106+
Time = 0, // We don't have actual timing data in RequestLog
107+
Request = new HarRequest
108+
{
109+
Method = request.Method,
110+
Url = request.RequestUri?.ToString(),
111+
HttpVersion = $"HTTP/{request.HttpVersion}",
112+
Headers = [.. request.Headers.Select(h => new HarHeader { Name = h.Name, Value = GetHeaderValue(h.Name, string.Join(", ", h.Value)) })],
113+
QueryString = [.. HttpUtility.ParseQueryString(request.RequestUri?.Query ?? "")
114+
.AllKeys
115+
.Where(key => key is not null)
116+
.Select(key => new HarQueryParam { Name = key!, Value = HttpUtility.ParseQueryString(request.RequestUri?.Query ?? "")[key] ?? "" })],
117+
Cookies = [.. request.Headers
118+
.Where(h => string.Equals(h.Name, "Cookie", StringComparison.OrdinalIgnoreCase))
119+
.Select(h => h.Value)
120+
.SelectMany(v => v.Split(';'))
121+
.Select(c =>
122+
{
123+
var parts = c.Split('=', 2);
124+
return new HarCookie { Name = parts[0].Trim(), Value = parts.Length > 1 ? parts[1].Trim() : "" };
125+
})],
126+
HeadersSize = request.Headers?.ToString()?.Length ?? 0,
127+
BodySize = request.HasBody ? (request.BodyString?.Length ?? 0) : 0,
128+
PostData = request.HasBody ? new HarPostData
129+
{
130+
MimeType = request.ContentType,
131+
Text = request.BodyString ?? ""
132+
}
133+
: null
134+
},
135+
Response = response is not null ? new HarResponse
136+
{
137+
Status = response.StatusCode,
138+
StatusText = response.StatusDescription,
139+
HttpVersion = $"HTTP/{response.HttpVersion}",
140+
Headers = [.. response.Headers.Select(h => new HarHeader { Name = h.Name, Value = GetHeaderValue(h.Name, string.Join(", ", h.Value)) })],
141+
Cookies = [.. response.Headers
142+
.Where(h => string.Equals(h.Name, "Set-Cookie", StringComparison.OrdinalIgnoreCase))
143+
.Select(h => h.Value)
144+
.Select(sc =>
145+
{
146+
var parts = sc.Split(';')[0].Split('=', 2);
147+
return new HarCookie { Name = parts[0].Trim(), Value = parts.Length > 1 ? parts[1].Trim() : "" };
148+
})],
149+
Content = new HarContent
150+
{
151+
Size = response.HasBody ? (response.BodyString?.Length ?? 0) : 0,
152+
MimeType = response.ContentType ?? "",
153+
Text = Configuration.IncludeResponse && response.HasBody ? response.BodyString : null
154+
},
155+
HeadersSize = response.Headers?.ToString()?.Length ?? 0,
156+
BodySize = response.HasBody ? (response.BodyString?.Length ?? 0) : 0
157+
} : null
158+
};
159+
160+
return entry;
161+
}
162+
}

DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,13 +288,13 @@ private void SetParametersFromRequestHeaders(OpenApiOperation operation, HeaderC
288288
foreach (var header in headers)
289289
{
290290
var lowerCaseHeaderName = header.Name.ToLowerInvariant();
291-
if (Http.StandardHeaders.Contains(lowerCaseHeaderName))
291+
if (Models.Http.StandardHeaders.Contains(lowerCaseHeaderName))
292292
{
293293
Logger.LogDebug(" Skipping standard header {HeaderName}", header.Name);
294294
continue;
295295
}
296296

297-
if (Http.AuthHeaders.Contains(lowerCaseHeaderName))
297+
if (Models.Http.AuthHeaders.Contains(lowerCaseHeaderName))
298298
{
299299
Logger.LogDebug(" Skipping auth header {HeaderName}", header.Name);
300300
continue;
@@ -388,13 +388,13 @@ private void SetResponseFromSession(OpenApiOperation operation, Response respons
388388
foreach (var header in response.Headers)
389389
{
390390
var lowerCaseHeaderName = header.Name.ToLowerInvariant();
391-
if (Http.StandardHeaders.Contains(lowerCaseHeaderName))
391+
if (Models.Http.StandardHeaders.Contains(lowerCaseHeaderName))
392392
{
393393
Logger.LogDebug(" Skipping standard header {HeaderName}", header.Name);
394394
continue;
395395
}
396396

397-
if (Http.AuthHeaders.Contains(lowerCaseHeaderName))
397+
if (Models.Http.AuthHeaders.Contains(lowerCaseHeaderName))
398398
{
399399
Logger.LogDebug(" Skipping auth header {HeaderName}", header.Name);
400400
continue;

DevProxy.Plugins/Generation/TypeSpecGeneratorPlugin.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ private void ProcessAuth(Request httpRequest, TypeSpecFile doc, Operation op)
175175
Logger.LogTrace("Entered {Name}", nameof(ProcessAuth));
176176

177177
var authHeaders = httpRequest.Headers
178-
.Where(h => Http.AuthHeaders.Contains(h.Name.ToLowerInvariant()))
178+
.Where(h => Models.Http.AuthHeaders.Contains(h.Name.ToLowerInvariant()))
179179
.Select(h => (h.Name, h.Value));
180180

181181
foreach (var (name, value) in authHeaders)
@@ -199,7 +199,7 @@ private void ProcessAuth(Request httpRequest, TypeSpecFile doc, Operation op)
199199

200200
var query = HttpUtility.ParseQueryString(httpRequest.RequestUri.Query);
201201
var authQueryParam = query.AllKeys
202-
.FirstOrDefault(k => k is not null && Http.AuthHeaders.Contains(k.ToLowerInvariant()));
202+
.FirstOrDefault(k => k is not null && Models.Http.AuthHeaders.Contains(k.ToLowerInvariant()));
203203
if (authQueryParam is not null)
204204
{
205205
Logger.LogDebug("Found auth query parameter: {AuthQueryParam}", authQueryParam);
@@ -357,8 +357,8 @@ private void ProcessRequestHeaders(Request httpRequest, Operation op)
357357

358358
foreach (var header in httpRequest.Headers)
359359
{
360-
if (Http.StandardHeaders.Contains(header.Name.ToLowerInvariant()) ||
361-
Http.AuthHeaders.Contains(header.Name.ToLowerInvariant()))
360+
if (Models.Http.StandardHeaders.Contains(header.Name.ToLowerInvariant()) ||
361+
Models.Http.AuthHeaders.Contains(header.Name.ToLowerInvariant()))
362362
{
363363
continue;
364364
}
@@ -400,8 +400,8 @@ private async Task ProcessResponseAsync(Response? httpResponse, TypeSpecFile doc
400400
{
401401
StatusCode = httpResponse.StatusCode,
402402
Headers = httpResponse.Headers
403-
.Where(h => !Http.StandardHeaders.Contains(h.Name.ToLowerInvariant()) &&
404-
!Http.AuthHeaders.Contains(h.Name.ToLowerInvariant()))
403+
.Where(h => !Models.Http.StandardHeaders.Contains(h.Name.ToLowerInvariant()) &&
404+
!Models.Http.AuthHeaders.Contains(h.Name.ToLowerInvariant()))
405405
.ToDictionary(h => h.Name.ToCamelCase(), h => h.Value.GetType().Name)
406406
};
407407

@@ -688,7 +688,7 @@ private bool IsParametrizable(string segment)
688688
var query = HttpUtility.ParseQueryString(url.Query);
689689
foreach (string key in query.Keys)
690690
{
691-
if (Http.AuthHeaders.Contains(key.ToLowerInvariant()))
691+
if (Models.Http.AuthHeaders.Contains(key.ToLowerInvariant()))
692692
{
693693
Logger.LogDebug("Skipping auth header: {Key}", key);
694694
continue;

DevProxy.Plugins/Models/Har.cs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace DevProxy.Plugins.Models;
6+
7+
internal sealed class HarFile
8+
{
9+
public HarLog? Log { get; set; }
10+
}
11+
12+
internal sealed class HarLog
13+
{
14+
public string Version { get; set; } = "1.2";
15+
public HarCreator Creator { get; set; } = new();
16+
public List<HarEntry> Entries { get; set; } = [];
17+
}
18+
19+
internal sealed class HarCreator
20+
{
21+
public string? Name { get; set; }
22+
public string? Version { get; set; }
23+
}
24+
25+
internal sealed class HarEntry
26+
{
27+
public string? StartedDateTime { get; set; }
28+
public double Time { get; set; }
29+
public HarRequest? Request { get; set; }
30+
public HarResponse? Response { get; set; }
31+
public HarCache Cache { get; set; } = new();
32+
public HarTimings Timings { get; set; } = new();
33+
}
34+
35+
internal sealed class HarRequest
36+
{
37+
public string? Method { get; set; }
38+
public string? Url { get; set; }
39+
public string? HttpVersion { get; set; }
40+
public List<HarHeader> Headers { get; set; } = [];
41+
public List<HarQueryParam> QueryString { get; set; } = [];
42+
public List<HarCookie> Cookies { get; set; } = [];
43+
public long HeadersSize { get; set; }
44+
public long BodySize { get; set; }
45+
public HarPostData? PostData { get; set; }
46+
}
47+
48+
internal sealed class HarResponse
49+
{
50+
public int Status { get; set; }
51+
public string? StatusText { get; set; }
52+
public string? HttpVersion { get; set; }
53+
public List<HarHeader> Headers { get; set; } = [];
54+
public List<HarCookie> Cookies { get; set; } = [];
55+
public HarContent Content { get; set; } = new();
56+
public string RedirectURL { get; set; } = "";
57+
public long HeadersSize { get; set; }
58+
public long BodySize { get; set; }
59+
}
60+
61+
internal sealed class HarHeader
62+
{
63+
public string? Name { get; set; }
64+
public string? Value { get; set; }
65+
}
66+
67+
internal sealed class HarQueryParam
68+
{
69+
public string? Name { get; set; }
70+
public string? Value { get; set; }
71+
}
72+
73+
internal sealed class HarCookie
74+
{
75+
public string? Name { get; set; }
76+
public string? Value { get; set; }
77+
public string? Path { get; set; }
78+
public string? Domain { get; set; }
79+
public string? Expires { get; set; }
80+
public bool? HttpOnly { get; set; }
81+
public bool? Secure { get; set; }
82+
}
83+
84+
internal sealed class HarPostData
85+
{
86+
public string? MimeType { get; set; }
87+
public string? Text { get; set; }
88+
public List<HarParam>? Params { get; set; }
89+
}
90+
91+
internal sealed class HarParam
92+
{
93+
public string? Name { get; set; }
94+
public string? Value { get; set; }
95+
public string? FileName { get; set; }
96+
public string? ContentType { get; set; }
97+
}
98+
99+
internal sealed class HarContent
100+
{
101+
public long Size { get; set; }
102+
public string MimeType { get; set; } = "";
103+
public string? Text { get; set; }
104+
public string? Encoding { get; set; }
105+
}
106+
107+
internal sealed class HarCache
108+
{
109+
// Minimal - can be expanded if needed
110+
}
111+
112+
internal sealed class HarTimings
113+
{
114+
public double Send { get; set; }
115+
public double Wait { get; set; }
116+
public double Receive { get; set; }
117+
}

DevProxy.Plugins/Models/Http.cs

Lines changed: 20 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+
namespace DevProxy.Plugins.Models;
6+
57
internal static class Http
68
{
79
// from: https://github.com/jonluca/har-to-openapi/blob/0d44409162c0a127cdaccd60b0a270ecd361b829/src/utils/headers.ts
@@ -237,4 +239,22 @@ internal static class Http
237239
"apikey",
238240
"code"
239241
];
242+
243+
internal static readonly string[] SensitiveHeaders =
244+
[
245+
"authorization",
246+
"cookie",
247+
"from",
248+
"proxy-authenticate",
249+
"proxy-authorization",
250+
"set-cookie",
251+
"www-authenticate",
252+
"x-api-key",
253+
"x-auth-token",
254+
"x-csrf-token",
255+
"x-forwarded-for",
256+
"x-real-ip",
257+
"x-session-token",
258+
"x-xsrf-token"
259+
];
240260
}

0 commit comments

Comments
 (0)