From aa2de7aca628eec2a6a71a54cb66e3cc0fac58c0 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Wed, 22 May 2024 13:21:59 +0200 Subject: [PATCH] Reporters (#714) --- dev-proxy-abstractions/BaseReportingPlugin.cs | 17 + dev-proxy-abstractions/ProxyUtils.cs | 2 + .../MinimalPermissions/PermissionsType.cs | 2 +- .../MinimalPermissions/RequestInfo.cs | 2 +- dev-proxy-plugins/Reporters/BaseReporter.cs | 63 +++ dev-proxy-plugins/Reporters/JsonReporter.cs | 64 +++ .../Reporters/MarkdownReporter.cs | 387 ++++++++++++++++++ .../Reporters/PlainTextReporter.cs | 332 +++++++++++++++ .../RequestLogs/ApiCenterOnboardingPlugin.cs | 50 ++- .../ApiCenterProductionVersionPlugin.cs | 65 ++- .../RequestLogs/ExecutionSummaryPlugin.cs | 196 +-------- .../MinimalPermissionsGuidancePlugin.cs | 105 +---- .../RequestLogs/MinimalPermissionsPlugin.cs | 36 +- .../RequestLogs/MockGeneratorPlugin.cs | 4 +- .../RequestLogs/OpenApiSpecGeneratorPlugin.cs | 24 +- dev-proxy/PluginLoader.cs | 33 +- dev-proxy/ProxyEngine.cs | 6 +- 17 files changed, 1084 insertions(+), 304 deletions(-) create mode 100644 dev-proxy-abstractions/BaseReportingPlugin.cs create mode 100644 dev-proxy-plugins/Reporters/BaseReporter.cs create mode 100644 dev-proxy-plugins/Reporters/JsonReporter.cs create mode 100644 dev-proxy-plugins/Reporters/MarkdownReporter.cs create mode 100644 dev-proxy-plugins/Reporters/PlainTextReporter.cs diff --git a/dev-proxy-abstractions/BaseReportingPlugin.cs b/dev-proxy-abstractions/BaseReportingPlugin.cs new file mode 100644 index 00000000..6eeb1950 --- /dev/null +++ b/dev-proxy-abstractions/BaseReportingPlugin.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DevProxy.Abstractions; + +public abstract class BaseReportingPlugin : BaseProxyPlugin +{ + protected virtual void StoreReport(object report, ProxyEventArgsBase e) + { + if (report is null) + { + return; + } + + ((Dictionary)e.GlobalData[ProxyUtils.ReportsKey])[Name] = report; + } +} diff --git a/dev-proxy-abstractions/ProxyUtils.cs b/dev-proxy-abstractions/ProxyUtils.cs index b4bbe92b..44f47119 100644 --- a/dev-proxy-abstractions/ProxyUtils.cs +++ b/dev-proxy-abstractions/ProxyUtils.cs @@ -37,6 +37,8 @@ public static class ProxyUtils // doesn't end with a path separator public static string? AppFolder => Path.GetDirectoryName(AppContext.BaseDirectory); + public static readonly string ReportsKey = "Reports"; + public static bool IsGraphRequest(Request request) => IsGraphUrl(request.RequestUri); public static bool IsGraphUrl(Uri uri) => diff --git a/dev-proxy-plugins/MinimalPermissions/PermissionsType.cs b/dev-proxy-plugins/MinimalPermissions/PermissionsType.cs index 3b4b108c..02dd9a3e 100644 --- a/dev-proxy-plugins/MinimalPermissions/PermissionsType.cs +++ b/dev-proxy-plugins/MinimalPermissions/PermissionsType.cs @@ -1,6 +1,6 @@ namespace Microsoft.DevProxy.Plugins.RequestLogs.MinimalPermissions; -internal enum PermissionsType +public enum PermissionsType { Application, Delegated diff --git a/dev-proxy-plugins/MinimalPermissions/RequestInfo.cs b/dev-proxy-plugins/MinimalPermissions/RequestInfo.cs index 1e26e636..8ebfc666 100644 --- a/dev-proxy-plugins/MinimalPermissions/RequestInfo.cs +++ b/dev-proxy-plugins/MinimalPermissions/RequestInfo.cs @@ -3,7 +3,7 @@ namespace Microsoft.DevProxy.Plugins.RequestLogs.MinimalPermissions; -internal class RequestInfo +public class RequestInfo { [JsonPropertyName("requestUrl")] public string Url { get; set; } = string.Empty; diff --git a/dev-proxy-plugins/Reporters/BaseReporter.cs b/dev-proxy-plugins/Reporters/BaseReporter.cs new file mode 100644 index 00000000..8ce38a15 --- /dev/null +++ b/dev-proxy-plugins/Reporters/BaseReporter.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DevProxy.Abstractions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DevProxy.Plugins.Reporters; + +public abstract class BaseReporter : BaseProxyPlugin +{ + public virtual string FileExtension => throw new NotImplementedException(); + + public override void Register(IPluginEvents pluginEvents, + IProxyContext context, + ISet urlsToWatch, + IConfigurationSection? configSection = null) + { + base.Register(pluginEvents, context, urlsToWatch, configSection); + + pluginEvents.AfterRecordingStop += AfterRecordingStop; + } + + protected abstract string? GetReport(KeyValuePair report); + + protected virtual Task AfterRecordingStop(object sender, RecordingArgs e) + { + if (!e.GlobalData.ContainsKey(ProxyUtils.ReportsKey) || + e.GlobalData[ProxyUtils.ReportsKey] is not Dictionary reports || + !reports.Any()) + { + _logger?.LogDebug("No reports found"); + return Task.CompletedTask; + } + + foreach (var report in reports) + { + _logger?.LogDebug("Transforming report {reportKey}...", report.Key); + + var reportContents = GetReport(report); + + if (string.IsNullOrEmpty(reportContents)) + { + _logger?.LogDebug("Report {reportKey} is empty, ignore", report.Key); + continue; + } + + var fileName = $"{report.Key}_{Name}{FileExtension}"; + _logger?.LogDebug("File name for report {report}: {fileName}", report.Key, fileName); + + if (File.Exists(fileName)) + { + _logger?.LogDebug("File {fileName} already exists, appending timestamp", fileName); + fileName = $"{report.Key}_{Name}_{DateTime.Now:yyyyMMddHHmmss}{FileExtension}"; + } + + _logger?.LogDebug("Writing report {reportKey} to {fileName}...", report.Key, fileName); + File.WriteAllText(fileName, reportContents); + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/dev-proxy-plugins/Reporters/JsonReporter.cs b/dev-proxy-plugins/Reporters/JsonReporter.cs new file mode 100644 index 00000000..6392932b --- /dev/null +++ b/dev-proxy-plugins/Reporters/JsonReporter.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.DevProxy.Abstractions; +using Microsoft.DevProxy.Plugins.RequestLogs; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DevProxy.Plugins.Reporters; + +public class JsonReporter : BaseReporter +{ + public override string Name => nameof(JsonReporter); + public override string FileExtension => ".json"; + + private readonly Dictionary> _transformers = new() + { + { typeof(ExecutionSummaryPluginReportByUrl), TransformExecutionSummary }, + { typeof(ExecutionSummaryPluginReportByMessageType), TransformExecutionSummary }, + }; + + protected override string GetReport(KeyValuePair report) + { + _logger?.LogDebug("Serializing report {reportKey}...", report.Key); + + var reportData = report.Value; + var reportType = reportData.GetType(); + + if (_transformers.TryGetValue(reportType, out var transform)) + { + _logger?.LogDebug("Transforming {reportType} using {transform}...", reportType.Name, transform.Method.Name); + reportData = transform(reportData); + } + else + { + _logger?.LogDebug("No transformer found for {reportType}", reportType.Name); + } + + if (reportData is string strVal) + { + _logger?.LogDebug("{reportKey} is a string. Checking if it's JSON...", report.Key); + + try + { + JsonSerializer.Deserialize(strVal); + _logger?.LogDebug("{reportKey} is already JSON, ignore", report.Key); + // already JSON, ignore + return strVal; + } + catch + { + _logger?.LogDebug("{reportKey} is not JSON, serializing...", report.Key); + } + } + + return JsonSerializer.Serialize(reportData, ProxyUtils.JsonSerializerOptions); + } + + private static object TransformExecutionSummary(object report) + { + var executionSummaryReport = (ExecutionSummaryPluginReportBase)report; + return executionSummaryReport.Data; + } +} \ No newline at end of file diff --git a/dev-proxy-plugins/Reporters/MarkdownReporter.cs b/dev-proxy-plugins/Reporters/MarkdownReporter.cs new file mode 100644 index 00000000..01e29a33 --- /dev/null +++ b/dev-proxy-plugins/Reporters/MarkdownReporter.cs @@ -0,0 +1,387 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using Microsoft.DevProxy.Abstractions; +using Microsoft.DevProxy.Plugins.RequestLogs; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DevProxy.Plugins.Reporters; + +public class MarkdownReporter : BaseReporter +{ + public override string Name => nameof(MarkdownReporter); + public override string FileExtension => ".md"; + + private readonly Dictionary> _transformers = new() + { + { typeof(ApiCenterOnboardingPluginReport), TransformApiCenterOnboardingReport }, + { typeof(ApiCenterProductionVersionPluginReport), TransformApiCenterProductionVersionReport }, + { typeof(ExecutionSummaryPluginReportByUrl), TransformExecutionSummaryByUrl }, + { typeof(ExecutionSummaryPluginReportByMessageType), TransformExecutionSummaryByMessageType }, + { typeof(MinimalPermissionsGuidancePluginReport), TransformMinimalPermissionsGuidanceReport }, + { typeof(MinimalPermissionsPluginReport), TransformMinimalPermissionsReport }, + { typeof(OpenApiSpecGeneratorPluginReport), TransformOpenApiSpecGeneratorReport } + }; + + private const string _requestsInterceptedMessage = "Requests intercepted"; + private const string _requestsPassedThroughMessage = "Requests passed through"; + + protected override string? GetReport(KeyValuePair report) + { + _logger?.LogDebug("Transforming {report}...", report.Key); + + var reportType = report.Value.GetType(); + + if (_transformers.TryGetValue(reportType, out var transform)) + { + _logger?.LogDebug("Transforming {reportType} using {transform}...", reportType.Name, transform.Method.Name); + + return transform(report.Value); + } + else + { + _logger?.LogDebug("No transformer found for {reportType}", reportType.Name); + return null; + } + } + + private static string? TransformApiCenterOnboardingReport(object report) + { + var apiCenterOnboardingReport = (ApiCenterOnboardingPluginReport)report; + + if (!apiCenterOnboardingReport.NewApis.Any() && + !apiCenterOnboardingReport.ExistingApis.Any()) + { + return null; + } + + var sb = new StringBuilder(); + + sb.AppendLine("# Azure API Center onboarding report"); + sb.AppendLine(); + + if (apiCenterOnboardingReport.NewApis.Any()) + { + var apisPerSchemeAndHost = apiCenterOnboardingReport.NewApis.GroupBy(x => + { + var u = new Uri(x.Url); + return u.GetLeftPart(UriPartial.Authority); + }); + + sb.AppendLine("## ⚠️ New APIs that aren't registered in Azure API Center"); + sb.AppendLine(); + + foreach (var apiPerHost in apisPerSchemeAndHost) + { + sb.AppendLine($"### {apiPerHost.Key}"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, apiPerHost.Select(a => $"- {a.Method} {a.Url}")); + sb.AppendLine(); + } + + sb.AppendLine(); + } + + if (apiCenterOnboardingReport.ExistingApis.Any()) + { + sb.AppendLine("## ✅ APIs that are already registered in Azure API Center"); + sb.AppendLine(); + sb.AppendLine("API|Definition ID|Operation ID"); + sb.AppendLine("---|------------|------------"); + sb.AppendJoin(Environment.NewLine, apiCenterOnboardingReport.ExistingApis.Select(a => $"{a.MethodAndUrl}|{a.ApiDefinitionId}|{a.OperationId}")); + sb.AppendLine(); + } + + sb.AppendLine(); + + return sb.ToString(); + } + + private static string? TransformApiCenterProductionVersionReport(object report) + { + var getReadableApiStatus = (ApiCenterProductionVersionPluginReportItemStatus status) => status switch + { + ApiCenterProductionVersionPluginReportItemStatus.NotRegistered => "🛑 Not registered", + ApiCenterProductionVersionPluginReportItemStatus.NonProduction => "⚠️ Non-production", + ApiCenterProductionVersionPluginReportItemStatus.Production => "✅ Production", + _ => "Unknown" + }; + + var apiCenterProductionVersionReport = (ApiCenterProductionVersionPluginReport)report; + + var groupedPerStatus = apiCenterProductionVersionReport + .GroupBy(a => a.Status) + .OrderBy(g => (int)g.Key); + + var sb = new StringBuilder(); + sb.AppendLine("# Azure API Center lifecycle report"); + sb.AppendLine(); + + foreach (var group in groupedPerStatus) + { + sb.AppendLine($"## {getReadableApiStatus(group.Key)} APIs"); + sb.AppendLine(); + + if (group.Key == ApiCenterProductionVersionPluginReportItemStatus.NonProduction) + { + sb.AppendLine("API|Recommendation"); + sb.AppendLine("---|------------"); + sb.AppendJoin(Environment.NewLine, group + .OrderBy(a => a.Url) + .Select(a => $"{a.Method} {a.Url}|{a.Recommendation ?? ""}")); + sb.AppendLine(); + } + else + { + sb.AppendJoin(Environment.NewLine, group + .OrderBy(a => a.Url) + .Select(a => $"- {a.Method} {a.Url}")); + sb.AppendLine(); + } + + sb.AppendLine(); + } + + return sb.ToString(); + } + + private static string TransformExecutionSummaryByMessageType(object report) + { + var executionSummaryReport = (ExecutionSummaryPluginReportByMessageType)report; + + var sb = new StringBuilder(); + + sb.AppendLine("# Dev Proxy execution summary"); + sb.AppendLine(); + sb.AppendLine($"Date: {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}"); + sb.AppendLine(); + + sb.AppendLine("## Message types"); + + var data = executionSummaryReport.Data; + var sortedMessageTypes = data.Keys.OrderBy(k => k); + foreach (var messageType in sortedMessageTypes) + { + sb.AppendLine(); + sb.AppendLine($"### {messageType}"); + + if (messageType == _requestsInterceptedMessage || + messageType == _requestsPassedThroughMessage) + { + sb.AppendLine(); + + var sortedMethodAndUrls = data[messageType][messageType].Keys.OrderBy(k => k); + foreach (var methodAndUrl in sortedMethodAndUrls) + { + sb.AppendLine($"- ({data[messageType][messageType][methodAndUrl]}) {methodAndUrl}"); + } + } + else + { + var sortedMessages = data[messageType].Keys.OrderBy(k => k); + foreach (var message in sortedMessages) + { + sb.AppendLine(); + sb.AppendLine($"#### {message}"); + sb.AppendLine(); + + var sortedMethodAndUrls = data[messageType][message].Keys.OrderBy(k => k); + foreach (var methodAndUrl in sortedMethodAndUrls) + { + sb.AppendLine($"- ({data[messageType][message][methodAndUrl]}) {methodAndUrl}"); + } + } + } + } + + AddExecutionSummaryReportSummary(executionSummaryReport.Logs, sb); + sb.AppendLine(); + + return sb.ToString(); + } + + private static string TransformExecutionSummaryByUrl(object report) + { + var executionSummaryReport = (ExecutionSummaryPluginReportByUrl)report; + + var sb = new StringBuilder(); + + sb.AppendLine("# Dev Proxy execution summary"); + sb.AppendLine(); + sb.AppendLine($"Date: {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}"); + sb.AppendLine(); + + sb.AppendLine("## Requests"); + + var data = executionSummaryReport.Data; + var sortedMethodAndUrls = data.Keys.OrderBy(k => k); + foreach (var methodAndUrl in sortedMethodAndUrls) + { + sb.AppendLine(); + sb.AppendLine($"### {methodAndUrl}"); + + var sortedMessageTypes = data[methodAndUrl].Keys.OrderBy(k => k); + foreach (var messageType in sortedMessageTypes) + { + sb.AppendLine(); + sb.AppendLine($"#### {messageType}"); + sb.AppendLine(); + + var sortedMessages = data[methodAndUrl][messageType].Keys.OrderBy(k => k); + foreach (var message in sortedMessages) + { + sb.AppendLine($"- ({data[methodAndUrl][messageType][message]}) {message}"); + } + } + } + + AddExecutionSummaryReportSummary(executionSummaryReport.Logs, sb); + sb.AppendLine(); + + return sb.ToString(); + } + + private static void AddExecutionSummaryReportSummary(IEnumerable requestLogs, StringBuilder sb) + { + var getReadableMessageTypeForSummary = (MessageType messageType) => messageType switch + { + MessageType.Chaos => "Requests with chaos", + MessageType.Failed => "Failures", + MessageType.InterceptedRequest => _requestsInterceptedMessage, + MessageType.Mocked => "Requests mocked", + MessageType.PassedThrough => _requestsPassedThroughMessage, + MessageType.Tip => "Tips", + MessageType.Warning => "Warnings", + _ => "Unknown" + }; + + var data = requestLogs + .Where(log => log.MessageType != MessageType.InterceptedResponse) + .Select(log => getReadableMessageTypeForSummary(log.MessageType)) + .OrderBy(log => log) + .GroupBy(log => log) + .ToDictionary(group => group.Key, group => group.Count()); + + sb.AppendLine(); + sb.AppendLine("## Summary"); + sb.AppendLine(); + sb.AppendLine("Category|Count"); + sb.AppendLine("--------|----:"); + + foreach (var messageType in data.Keys) + { + sb.AppendLine($"{messageType}|{data[messageType]}"); + } + } + + private static string? TransformMinimalPermissionsGuidanceReport(object report) + { + var minimalPermissionsGuidanceReport = (MinimalPermissionsGuidancePluginReport)report; + + var sb = new StringBuilder(); + sb.AppendLine("# Minimal permissions report"); + sb.AppendLine(); + + var transformPermissionsInfo = (Action)((permissionsInfo, type) => + { + sb.AppendLine($"## Minimal {type} permissions"); + sb.AppendLine(); + sb.AppendLine("### Operations"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, permissionsInfo.Operations.Select(o => $"- {o.Method} {o.Endpoint}")); + sb.AppendLine(); + sb.AppendLine(); + sb.AppendLine("### Minimal permissions"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, permissionsInfo.MinimalPermissions.Select(p => $"- {p}")); + sb.AppendLine(); + sb.AppendLine(); + sb.AppendLine("### Permissions on the token"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, permissionsInfo.PermissionsFromTheToken.Select(p => $"- {p}")); + sb.AppendLine(); + sb.AppendLine(); + sb.AppendLine("### Excessive permissions"); + + if (permissionsInfo.ExcessPermissions.Any()) + { + sb.AppendLine(); + sb.AppendLine("The following permissions included in token are unnecessary:"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, permissionsInfo.ExcessPermissions.Select(p => $"- {p}")); + sb.AppendLine(); + } + else + { + sb.AppendLine(); + sb.AppendLine("The token has the minimal permissions required."); + } + + sb.AppendLine(); + }); + + if (minimalPermissionsGuidanceReport.DelegatedPermissions is not null) + { + transformPermissionsInfo(minimalPermissionsGuidanceReport.DelegatedPermissions, "delegated"); + } + if (minimalPermissionsGuidanceReport.ApplicationPermissions is not null) + { + transformPermissionsInfo(minimalPermissionsGuidanceReport.ApplicationPermissions, "application"); + } + + return sb.ToString(); + } + + private static string? TransformMinimalPermissionsReport(object report) + { + var minimalPermissionsReport = (MinimalPermissionsPluginReport)report; + + var sb = new StringBuilder(); + sb.AppendLine($"# Minimal {minimalPermissionsReport.PermissionsType.ToString().ToLower()} permissions report"); + sb.AppendLine(); + + sb.AppendLine("## Requests"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, minimalPermissionsReport.Requests.Select(r => $"- {r.Method} {r.Url}")); + sb.AppendLine(); + + sb.AppendLine(); + sb.AppendLine("## Minimal permissions"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, minimalPermissionsReport.MinimalPermissions.Select(p => $"- {p}")); + sb.AppendLine(); + + if (minimalPermissionsReport.Errors.Any()) + { + sb.AppendLine(); + sb.AppendLine("## 🛑 Errors"); + sb.AppendLine(); + sb.AppendLine("Couldn't determine minimal permissions for the following URLs:"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, minimalPermissionsReport.Errors.Select(e => $"- {e}")); + sb.AppendLine(); + } + + sb.AppendLine(); + + return sb.ToString(); + } + + private static string? TransformOpenApiSpecGeneratorReport(object report) + { + var openApiSpecGeneratorReport = (OpenApiSpecGeneratorPluginReport)report; + + var sb = new StringBuilder(); + + sb.AppendLine("# Generated OpenAPI specs"); + sb.AppendLine(); + sb.AppendLine("Server URL|File name"); + sb.AppendLine("---|---------"); + sb.AppendJoin(Environment.NewLine, openApiSpecGeneratorReport.Select(r => $"{r.ServerUrl}|{r.FileName}")); + sb.AppendLine(); + sb.AppendLine(); + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/dev-proxy-plugins/Reporters/PlainTextReporter.cs b/dev-proxy-plugins/Reporters/PlainTextReporter.cs new file mode 100644 index 00000000..2256c9a3 --- /dev/null +++ b/dev-proxy-plugins/Reporters/PlainTextReporter.cs @@ -0,0 +1,332 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using Microsoft.DevProxy.Abstractions; +using Microsoft.DevProxy.Plugins.RequestLogs; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DevProxy.Plugins.Reporters; + +public class PlainTextReporter : BaseReporter +{ + public override string Name => nameof(PlainTextReporter); + public override string FileExtension => ".txt"; + + private readonly Dictionary> _transformers = new() + { + { typeof(ApiCenterOnboardingPluginReport), TransformApiCenterOnboardingReport }, + { typeof(ApiCenterProductionVersionPluginReport), TransformApiCenterProductionVersionReport }, + { typeof(ExecutionSummaryPluginReportByUrl), TransformExecutionSummaryByUrl }, + { typeof(ExecutionSummaryPluginReportByMessageType), TransformExecutionSummaryByMessageType }, + { typeof(MinimalPermissionsGuidancePluginReport), TransformMinimalPermissionsGuidanceReport }, + { typeof(MinimalPermissionsPluginReport), TransformMinimalPermissionsReport }, + { typeof(OpenApiSpecGeneratorPluginReport), TransformOpenApiSpecGeneratorReport } + }; + + private const string _requestsInterceptedMessage = "Requests intercepted"; + private const string _requestsPassedThroughMessage = "Requests passed through"; + + protected override string? GetReport(KeyValuePair report) + { + _logger?.LogDebug("Transforming {report}...", report.Key); + + var reportType = report.Value.GetType(); + + if (_transformers.TryGetValue(reportType, out var transform)) + { + _logger?.LogDebug("Transforming {reportType} using {transform}...", reportType.Name, transform.Method); + + return transform(report.Value); + } + else + { + _logger?.LogDebug("No transformer found for {reportType}", reportType.Name); + return null; + } + } + + private static string? TransformOpenApiSpecGeneratorReport(object report) + { + var openApiSpecGeneratorReport = (OpenApiSpecGeneratorPluginReport)report; + + var sb = new StringBuilder(); + + sb.AppendLine("Generated OpenAPI specs:"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, openApiSpecGeneratorReport.Select(i => $"- {i.FileName} ({i.ServerUrl})")); + + return sb.ToString(); + } + + private static string? TransformExecutionSummaryByMessageType(object report) + { + var executionSummaryReport = (ExecutionSummaryPluginReportByMessageType)report; + + var sb = new StringBuilder(); + + sb.AppendLine("Dev Proxy execution summary"); + sb.AppendLine($"({DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")})"); + sb.AppendLine(); + + sb.AppendLine(":: Message types".ToUpper()); + + var data = executionSummaryReport.Data; + var sortedMessageTypes = data.Keys.OrderBy(k => k); + foreach (var messageType in sortedMessageTypes) + { + sb.AppendLine(); + sb.AppendLine(messageType.ToUpper()); + + if (messageType == _requestsInterceptedMessage || + messageType == _requestsPassedThroughMessage) + { + sb.AppendLine(); + + var sortedMethodAndUrls = data[messageType][messageType].Keys.OrderBy(k => k); + foreach (var methodAndUrl in sortedMethodAndUrls) + { + sb.AppendLine($"- ({data[messageType][messageType][methodAndUrl]}) {methodAndUrl}"); + } + } + else + { + var sortedMessages = data[messageType].Keys.OrderBy(k => k); + foreach (var message in sortedMessages) + { + sb.AppendLine(); + sb.AppendLine(message); + sb.AppendLine(); + + var sortedMethodAndUrls = data[messageType][message].Keys.OrderBy(k => k); + foreach (var methodAndUrl in sortedMethodAndUrls) + { + sb.AppendLine($"- ({data[messageType][message][methodAndUrl]}) {methodAndUrl}"); + } + } + } + } + + AddExecutionSummaryReportSummary(executionSummaryReport.Logs, sb); + + return sb.ToString(); + } + + private static string? TransformExecutionSummaryByUrl(object report) + { + var executionSummaryReport = (ExecutionSummaryPluginReportByUrl)report; + + var sb = new StringBuilder(); + + sb.AppendLine("Dev Proxy execution summary"); + sb.AppendLine($"({DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")})"); + sb.AppendLine(); + + sb.AppendLine(":: Requests".ToUpper()); + + var data = executionSummaryReport.Data; + var sortedMethodAndUrls = data.Keys.OrderBy(k => k); + foreach (var methodAndUrl in sortedMethodAndUrls) + { + sb.AppendLine(); + sb.AppendLine(methodAndUrl); + + var sortedMessageTypes = data[methodAndUrl].Keys.OrderBy(k => k); + foreach (var messageType in sortedMessageTypes) + { + sb.AppendLine(); + sb.AppendLine(messageType.ToUpper()); + sb.AppendLine(); + + var sortedMessages = data[methodAndUrl][messageType].Keys.OrderBy(k => k); + foreach (var message in sortedMessages) + { + sb.AppendLine($"- ({data[methodAndUrl][messageType][message]}) {message}"); + } + } + } + + AddExecutionSummaryReportSummary(executionSummaryReport.Logs, sb); + + return sb.ToString(); + } + + private static void AddExecutionSummaryReportSummary(IEnumerable requestLogs, StringBuilder sb) + { + var getReadableMessageTypeForSummary = (MessageType messageType) => messageType switch + { + MessageType.Chaos => "Requests with chaos", + MessageType.Failed => "Failures", + MessageType.InterceptedRequest => _requestsInterceptedMessage, + MessageType.Mocked => "Requests mocked", + MessageType.PassedThrough => _requestsPassedThroughMessage, + MessageType.Tip => "Tips", + MessageType.Warning => "Warnings", + _ => "Unknown" + }; + + var data = requestLogs + .Where(log => log.MessageType != MessageType.InterceptedResponse) + .Select(log => getReadableMessageTypeForSummary(log.MessageType)) + .OrderBy(log => log) + .GroupBy(log => log) + .ToDictionary(group => group.Key, group => group.Count()); + + sb.AppendLine(); + sb.AppendLine(":: Summary".ToUpper()); + sb.AppendLine(); + + foreach (var messageType in data.Keys) + { + sb.AppendLine($"{messageType} ({data[messageType]})"); + } + } + + private static string? TransformApiCenterProductionVersionReport(object report) + { + var getReadableApiStatus = (ApiCenterProductionVersionPluginReportItemStatus status) => status switch + { + ApiCenterProductionVersionPluginReportItemStatus.NotRegistered => "Not registered", + ApiCenterProductionVersionPluginReportItemStatus.NonProduction => "Non-production", + ApiCenterProductionVersionPluginReportItemStatus.Production => "Production", + _ => "Unknown" + }; + + var apiCenterProductionVersionReport = (ApiCenterProductionVersionPluginReport)report; + + var groupedPerStatus = apiCenterProductionVersionReport + .GroupBy(a => a.Status) + .OrderBy(g => (int)g.Key); + + var sb = new StringBuilder(); + + foreach (var group in groupedPerStatus) + { + sb.AppendLine($"{getReadableApiStatus(group.Key)} APIs:"); + sb.AppendLine(); + + sb.AppendJoin(Environment.NewLine, group.Select(a => $" {a.Method} {a.Url}")); + sb.AppendLine(); + } + + return sb.ToString(); + } + + private static string? TransformApiCenterOnboardingReport(object report) + { + var apiCenterOnboardingReport = (ApiCenterOnboardingPluginReport)report; + + if (!apiCenterOnboardingReport.NewApis.Any() && + !apiCenterOnboardingReport.ExistingApis.Any()) + { + return null; + } + + var sb = new StringBuilder(); + + if (apiCenterOnboardingReport.NewApis.Any()) + { + var apisPerSchemeAndHost = apiCenterOnboardingReport.NewApis.GroupBy(x => + { + var u = new Uri(x.Url); + return u.GetLeftPart(UriPartial.Authority); + }); + + sb.AppendLine("New APIs that aren't registered in Azure API Center:"); + sb.AppendLine(); + + foreach (var apiPerHost in apisPerSchemeAndHost) + { + sb.AppendLine($"{apiPerHost.Key}:"); + sb.AppendJoin(Environment.NewLine, apiPerHost.Select(a => $" {a.Method} {a.Url}")); + } + + sb.AppendLine(); + } + + if (apiCenterOnboardingReport.ExistingApis.Any()) + { + sb.AppendLine("APIs that are already registered in Azure API Center:"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, apiCenterOnboardingReport.ExistingApis.Select(a => a.MethodAndUrl)); + } + + return sb.ToString(); + } + + private static string? TransformMinimalPermissionsReport(object report) + { + var minimalPermissionsReport = (MinimalPermissionsPluginReport)report; + + var sb = new StringBuilder(); + + sb.AppendLine($"Minimal {minimalPermissionsReport.PermissionsType.ToString().ToLower()} permissions report"); + sb.AppendLine(); + sb.AppendLine("Requests:"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, minimalPermissionsReport.Requests.Select(r => $"- {r.Method} {r.Url}")); + sb.AppendLine(); + sb.AppendLine(); + sb.AppendLine("Minimal permissions:"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, minimalPermissionsReport.MinimalPermissions.Select(p => $"- {p}")); + + if (minimalPermissionsReport.Errors.Any()) + { + sb.AppendLine(); + sb.AppendLine("Couldn't determine minimal permissions for the following URLs:"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, minimalPermissionsReport.Errors.Select(e => $"- {e}")); + } + + return sb.ToString(); + } + + private static string? TransformMinimalPermissionsGuidanceReport(object report) + { + var minimalPermissionsGuidanceReport = (MinimalPermissionsGuidancePluginReport)report; + + var sb = new StringBuilder(); + + var transformPermissionsInfo = (Action)((permissionsInfo, type) => + { + sb.AppendLine($"{type} permissions for:"); + sb.AppendLine(); + sb.AppendLine(string.Join(Environment.NewLine, permissionsInfo.Operations.Select(o => $"- {o.Method} {o.Endpoint}"))); + sb.AppendLine(); + sb.AppendLine("Minimal permissions:"); + sb.AppendLine(); + sb.AppendLine(string.Join(", ", permissionsInfo.MinimalPermissions)); + sb.AppendLine(); + sb.AppendLine("Permissions on the token:"); + sb.AppendLine(); + sb.AppendLine(string.Join(", ", permissionsInfo.PermissionsFromTheToken)); + + if (permissionsInfo.ExcessPermissions.Any()) + { + sb.AppendLine(); + sb.AppendLine("The following permissions are unnecessary:"); + sb.AppendLine(); + sb.AppendLine(string.Join(", ", permissionsInfo.ExcessPermissions)); + } + else + { + sb.AppendLine(); + sb.AppendLine("The token has the minimal permissions required."); + } + + sb.AppendLine(); + }); + + if (minimalPermissionsGuidanceReport.DelegatedPermissions is not null) + { + transformPermissionsInfo(minimalPermissionsGuidanceReport.DelegatedPermissions, "Delegated"); + } + if (minimalPermissionsGuidanceReport.ApplicationPermissions is not null) + { + transformPermissionsInfo(minimalPermissionsGuidanceReport.ApplicationPermissions, "Application"); + } + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/dev-proxy-plugins/RequestLogs/ApiCenterOnboardingPlugin.cs b/dev-proxy-plugins/RequestLogs/ApiCenterOnboardingPlugin.cs index b493a0db..7379d61f 100644 --- a/dev-proxy-plugins/RequestLogs/ApiCenterOnboardingPlugin.cs +++ b/dev-proxy-plugins/RequestLogs/ApiCenterOnboardingPlugin.cs @@ -19,6 +19,25 @@ namespace Microsoft.DevProxy.Plugins.RequestLogs; +public class ApiCenterOnboardingPluginReportExistingApiInfo +{ + public required string MethodAndUrl { get; init; } + public required string ApiDefinitionId { get; init; } + public required string OperationId { get; init; } +} + +public class ApiCenterOnboardingPluginReportNewApiInfo +{ + public required string Method { get; init; } + public required string Url { get; init; } +} + +public class ApiCenterOnboardingPluginReport +{ + public required ApiCenterOnboardingPluginReportExistingApiInfo[] ExistingApis { get; init; } + public required ApiCenterOnboardingPluginReportNewApiInfo[] NewApis { get; init; } +} + internal class ApiCenterOnboardingPluginConfiguration { public string SubscriptionId { get; set; } = ""; @@ -28,7 +47,7 @@ internal class ApiCenterOnboardingPluginConfiguration public bool CreateApicEntryForNewApis { get; set; } = true; } -public class ApiCenterOnboardingPlugin : BaseProxyPlugin +public class ApiCenterOnboardingPlugin : BaseReportingPlugin { private ApiCenterOnboardingPluginConfiguration _configuration = new(); private readonly string[] _scopes = ["https://management.azure.com/.default"]; @@ -148,6 +167,9 @@ private async Task AfterRecordingStop(object sender, RecordingArgs e) }) .Where(r => !r.method.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase)) .Distinct(); + + var existingApis = new List(); + foreach (var request in interceptedRequests) { var (method, url) = request; @@ -188,20 +210,42 @@ private async Task AfterRecordingStop(object sender, RecordingArgs e) newApis.Add(new(method, url)); continue; } + + existingApis.Add(new ApiCenterOnboardingPluginReportExistingApiInfo + { + MethodAndUrl = $"{method} {url}", + ApiDefinitionId = apiDefinition.Id, + OperationId = operation.OperationId + }); } if (!newApis.Any()) { _logger?.LogInformation("No new APIs found"); + StoreReport(new ApiCenterOnboardingPluginReport + { + ExistingApis = existingApis.ToArray(), + NewApis = Array.Empty() + }, e); return; } // dedupe newApis newApis = newApis.Distinct().ToList(); + StoreReport(new ApiCenterOnboardingPluginReport + { + ExistingApis = existingApis.ToArray(), + NewApis = newApis.Select(a => new ApiCenterOnboardingPluginReportNewApiInfo + { + Method = a.method, + Url = a.url + }).ToArray() + }, e); + var apisPerSchemeAndHost = newApis.GroupBy(x => { - var u = new Uri(x.Item2); + var u = new Uri(x.url); return u.GetLeftPart(UriPartial.Authority); }); @@ -265,8 +309,6 @@ async Task CreateApisInApiCenter(IEnumerable CreateApi(string schemeAndHost, IEnumerable<(string method, string url)> apiRequests) diff --git a/dev-proxy-plugins/RequestLogs/ApiCenterProductionVersionPlugin.cs b/dev-proxy-plugins/RequestLogs/ApiCenterProductionVersionPlugin.cs index 5e16cb2e..5b44e173 100644 --- a/dev-proxy-plugins/RequestLogs/ApiCenterProductionVersionPlugin.cs +++ b/dev-proxy-plugins/RequestLogs/ApiCenterProductionVersionPlugin.cs @@ -5,16 +5,36 @@ using System.Diagnostics.Tracing; using System.Net.Http.Json; using System.Text.Json; +using System.Text.Json.Serialization; using Azure.Core; using Azure.Core.Diagnostics; using Azure.Identity; using Microsoft.DevProxy.Abstractions; -using Microsoft.DevProxy.Plugins; using Microsoft.DevProxy.Plugins.RequestLogs.ApiCenter; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.OpenApi.Readers; +namespace Microsoft.DevProxy.Plugins.RequestLogs; + +public enum ApiCenterProductionVersionPluginReportItemStatus +{ + NotRegistered, + NonProduction, + Production +} + +public class ApiCenterProductionVersionPluginReportItem +{ + public required string Method { get; init; } + public required string Url { get; init; } + [JsonConverter(typeof(JsonStringEnumConverter))] + public required ApiCenterProductionVersionPluginReportItemStatus Status { get; init; } + public string? Recommendation { get; init; } +} + +public class ApiCenterProductionVersionPluginReport: List; + internal class ApiInformation { public string Name { get; set; } = ""; @@ -37,7 +57,7 @@ internal class ApiCenterProductionVersionPluginConfiguration public string WorkspaceName { get; set; } = "default"; } -public class ApiCenterProductionVersionPlugin : BaseProxyPlugin +public class ApiCenterProductionVersionPlugin : BaseReportingPlugin { private ApiCenterProductionVersionPluginConfiguration _configuration = new(); private readonly string[] _scopes = ["https://management.azure.com/.default"]; @@ -227,6 +247,8 @@ private async Task AfterRecordingStop(object sender, RecordingArgs e) _logger?.LogInformation("Analyzing recorded requests..."); + var report = new ApiCenterProductionVersionPluginReport(); + foreach (var request in interceptedRequests) { var methodAndUrlString = request.MessageLines.First(); @@ -240,12 +262,24 @@ private async Task AfterRecordingStop(object sender, RecordingArgs e) var apiInformation = FindMatchingApiInformation(methodAndUrl[1], apisInformation); if (apiInformation == null) { + report.Add(new() + { + Method = methodAndUrl[0], + Url = methodAndUrl[1], + Status = ApiCenterProductionVersionPluginReportItemStatus.NotRegistered + }); continue; } var lifecycleStage = FindMatchingApiLifecycleStage(request, methodAndUrl[1], apiInformation); if (lifecycleStage == null) { + report.Add(new() + { + Method = methodAndUrl[0], + Url = methodAndUrl[1], + Status = ApiCenterProductionVersionPluginReportItemStatus.NotRegistered + }); continue; } @@ -255,18 +289,31 @@ private async Task AfterRecordingStop(object sender, RecordingArgs e) .Where(v => v.LifecycleStage == ApiLifecycleStage.Production) .Select(v => v.Title); - if (productionVersions.Any()) + var recommendation = productionVersions.Any() ? + string.Format("Request {0} uses API version {1} which is defined as {2}. Upgrade to a production version of the API. Recommended versions: {3}", methodAndUrlString, apiInformation.Versions.First(v => v.LifecycleStage == lifecycleStage).Title, lifecycleStage, string.Join(", ", productionVersions)) : + string.Format("Request {0} uses API version {1} which is defined as {2}.", methodAndUrlString, apiInformation.Versions.First(v => v.LifecycleStage == lifecycleStage).Title, lifecycleStage); + + _logger?.LogWarning(recommendation); + report.Add(new() { - _logger?.LogWarning("Request {request} uses API version {version} which is defined as {lifecycleStage}. Upgrade to a production version of the API. Recommended versions: {versions}", methodAndUrlString, apiInformation.Versions.First(v => v.LifecycleStage == lifecycleStage).Title, lifecycleStage, string.Join(", ", productionVersions)); - } - else + Method = methodAndUrl[0], + Url = methodAndUrl[1], + Status = ApiCenterProductionVersionPluginReportItemStatus.NonProduction, + Recommendation = recommendation + }); + } + else + { + report.Add(new() { - _logger?.LogWarning("Request {request} uses API version {version} which is defined as {lifecycleStage}.", methodAndUrlString, apiInformation.Versions.First(v => v.LifecycleStage == lifecycleStage).Title, lifecycleStage); - } + Method = methodAndUrl[0], + Url = methodAndUrl[1], + Status = ApiCenterProductionVersionPluginReportItemStatus.Production + }); } } - _logger?.LogInformation("DONE"); + StoreReport(report, e); } private async Task?> LoadApiDefinitionsForVersion(string versionId) diff --git a/dev-proxy-plugins/RequestLogs/ExecutionSummaryPlugin.cs b/dev-proxy-plugins/RequestLogs/ExecutionSummaryPlugin.cs index c8119396..fa72b845 100644 --- a/dev-proxy-plugins/RequestLogs/ExecutionSummaryPlugin.cs +++ b/dev-proxy-plugins/RequestLogs/ExecutionSummaryPlugin.cs @@ -5,11 +5,18 @@ using Microsoft.DevProxy.Abstractions; using System.CommandLine; using System.CommandLine.Invocation; -using System.Text.Json.Serialization; -using Microsoft.Extensions.Logging; namespace Microsoft.DevProxy.Plugins.RequestLogs; +public abstract class ExecutionSummaryPluginReportBase +{ + public required Dictionary>> Data { get; init; } + public required IEnumerable Logs { get; init; } +} + +public class ExecutionSummaryPluginReportByUrl : ExecutionSummaryPluginReportBase; +public class ExecutionSummaryPluginReportByMessageType : ExecutionSummaryPluginReportBase; + internal enum SummaryGroupBy { Url, @@ -18,47 +25,19 @@ internal enum SummaryGroupBy internal class ExecutionSummaryPluginConfiguration { - public string FilePath { get; set; } = ""; public SummaryGroupBy GroupBy { get; set; } = SummaryGroupBy.Url; } -public class ExecutionSummaryPlugin : BaseProxyPlugin +public class ExecutionSummaryPlugin : BaseReportingPlugin { public override string Name => nameof(ExecutionSummaryPlugin); private ExecutionSummaryPluginConfiguration _configuration = new(); - private static readonly string _filePathOptionName = "--summary-file-path"; private static readonly string _groupByOptionName = "--summary-group-by"; private const string _requestsInterceptedMessage = "Requests intercepted"; private const string _requestsPassedThroughMessage = "Requests passed through"; public override Option[] GetOptions() { - var filePath = new Option(_filePathOptionName, "Path to the file where the summary should be saved. If not specified, the summary will be printed to the console. Path can be absolute or relative to the current working directory.") - { - ArgumentHelpName = "summary-file-path" - }; - filePath.AddValidator(input => - { - var outputFilePath = input.Tokens.First().Value; - if (string.IsNullOrEmpty(outputFilePath)) - { - return; - } - - var dirName = Path.GetDirectoryName(outputFilePath); - if (string.IsNullOrEmpty(dirName)) - { - // current directory exists so no need to check - return; - } - - var outputDir = Path.GetFullPath(dirName); - if (!Directory.Exists(outputDir)) - { - input.ErrorMessage = $"The directory {outputDir} does not exist."; - } - }); - var groupBy = new Option(_groupByOptionName, "Specifies how the information should be grouped in the summary. Available options: `url` (default), `messageType`.") { ArgumentHelpName = "summary-group-by" @@ -71,7 +50,7 @@ public override Option[] GetOptions() } }); - return [filePath, groupBy]; + return [groupBy]; } public override void Register(IPluginEvents pluginEvents, @@ -91,12 +70,6 @@ private void OnOptionsLoaded(object? sender, OptionsLoadedArgs e) { InvocationContext context = e.Context; - var filePath = context.ParseResult.GetValueForOption(_filePathOptionName, e.Options); - if (filePath is not null) - { - _configuration.FilePath = filePath; - } - var groupBy = context.ParseResult.GetValueForOption(_groupByOptionName, e.Options); if (groupBy is not null) { @@ -111,126 +84,18 @@ private Task AfterRecordingStop(object? sender, RecordingArgs e) return Task.CompletedTask; } - var report = _configuration.GroupBy switch + ExecutionSummaryPluginReportBase report = _configuration.GroupBy switch { - SummaryGroupBy.Url => GetGroupedByUrlReport(e.RequestLogs), - SummaryGroupBy.MessageType => GetGroupedByMessageTypeReport(e.RequestLogs), + SummaryGroupBy.Url => new ExecutionSummaryPluginReportByUrl { Data = GetGroupedByUrlData(e.RequestLogs), Logs = e.RequestLogs }, + SummaryGroupBy.MessageType => new ExecutionSummaryPluginReportByMessageType { Data = GetGroupedByMessageTypeData(e.RequestLogs), Logs = e.RequestLogs }, _ => throw new NotImplementedException() }; - if (string.IsNullOrEmpty(_configuration.FilePath)) - { - _logger?.LogInformation("Report:\r\n{report}", string.Join(Environment.NewLine, report)); - } - else - { - File.WriteAllLines(_configuration.FilePath, report); - } + StoreReport(report, e); return Task.CompletedTask; } - private string[] GetGroupedByUrlReport(IEnumerable requestLogs) - { - var report = new List(); - report.AddRange(GetReportTitle()); - report.Add("## Requests"); - - var data = GetGroupedByUrlData(requestLogs); - - var sortedMethodAndUrls = data.Keys.OrderBy(k => k); - foreach (var methodAndUrl in sortedMethodAndUrls) - { - report.AddRange(new[] { - "", - $"### {methodAndUrl}", - }); - - var sortedMessageTypes = data[methodAndUrl].Keys.OrderBy(k => k); - foreach (var messageType in sortedMessageTypes) - { - report.AddRange(new[] { - "", - $"#### {messageType}", - "" - }); - - var sortedMessages = data[methodAndUrl][messageType].Keys.OrderBy(k => k); - foreach (var message in sortedMessages) - { - report.Add($"- ({data[methodAndUrl][messageType][message]}) {message}"); - } - } - } - - report.AddRange(GetSummary(requestLogs)); - - return report.ToArray(); - } - - private string[] GetGroupedByMessageTypeReport(IEnumerable requestLogs) - { - var report = new List(); - report.AddRange(GetReportTitle()); - report.Add("## Message types"); - - var data = GetGroupedByMessageTypeData(requestLogs); - - var sortedMessageTypes = data.Keys.OrderBy(k => k); - foreach (var messageType in sortedMessageTypes) - { - report.AddRange(new[] { - "", - $"### {messageType}" - }); - - if (messageType == _requestsInterceptedMessage || - messageType == _requestsPassedThroughMessage) - { - report.Add(""); - - var sortedMethodAndUrls = data[messageType][messageType].Keys.OrderBy(k => k); - foreach (var methodAndUrl in sortedMethodAndUrls) - { - report.Add($"- ({data[messageType][messageType][methodAndUrl]}) {methodAndUrl}"); - } - } - else - { - var sortedMessages = data[messageType].Keys.OrderBy(k => k); - foreach (var message in sortedMessages) - { - report.AddRange(new[] { - "", - $"#### {message}", - "" - }); - - var sortedMethodAndUrls = data[messageType][message].Keys.OrderBy(k => k); - foreach (var methodAndUrl in sortedMethodAndUrls) - { - report.Add($"- ({data[messageType][message][methodAndUrl]}) {methodAndUrl}"); - } - } - } - } - - report.AddRange(GetSummary(requestLogs)); - - return report.ToArray(); - } - - private string[] GetReportTitle() - { - return new string[] - { - "# Dev Proxy execution summary", - "", - $"Date: {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}", - "" - }; - } - // in this method we're producing the follow data structure // request > message type > (count) message private Dictionary>> GetGroupedByUrlData(IEnumerable requestLogs) @@ -251,7 +116,7 @@ private Dictionary>> GetGroup var request = GetMethodAndUrl(log); if (!data.ContainsKey(request)) { - data.Add(request, new Dictionary>()); + data.Add(request, []); } continue; @@ -262,7 +127,7 @@ private Dictionary>> GetGroup var readableMessageType = GetReadableMessageTypeForSummary(log.MessageType); if (!data[methodAndUrl].ContainsKey(readableMessageType)) { - data[methodAndUrl].Add(readableMessageType, new Dictionary()); + data[methodAndUrl].Add(readableMessageType, []); } if (data[methodAndUrl][readableMessageType].ContainsKey(message)) @@ -349,7 +214,7 @@ private Dictionary>> GetGroup private string GetRequestMessage(RequestLog requestLog) { - return String.Join(' ', requestLog.MessageLines); + return string.Join(' ', requestLog.MessageLines); } private string GetMethodAndUrl(RequestLog requestLog) @@ -364,31 +229,6 @@ private string GetMethodAndUrl(RequestLog requestLog) } } - private string[] GetSummary(IEnumerable requestLogs) - { - var data = requestLogs - .Where(log => log.MessageType != MessageType.InterceptedResponse) - .Select(log => GetReadableMessageTypeForSummary(log.MessageType)) - .OrderBy(log => log) - .GroupBy(log => log) - .ToDictionary(group => group.Key, group => group.Count()); - - var summary = new List { - "", - "## Summary", - "", - "Category|Count", - "--------|----:" - }; - - foreach (var messageType in data.Keys) - { - summary.Add($"{messageType}|{data[messageType]}"); - } - - return summary.ToArray(); - } - private string GetReadableMessageTypeForSummary(MessageType messageType) => messageType switch { MessageType.Chaos => "Requests with chaos", diff --git a/dev-proxy-plugins/RequestLogs/MinimalPermissionsGuidancePlugin.cs b/dev-proxy-plugins/RequestLogs/MinimalPermissionsGuidancePlugin.cs index 42e106db..09be4a1a 100644 --- a/dev-proxy-plugins/RequestLogs/MinimalPermissionsGuidancePlugin.cs +++ b/dev-proxy-plugins/RequestLogs/MinimalPermissionsGuidancePlugin.cs @@ -5,26 +5,25 @@ using Microsoft.Extensions.Logging; using Microsoft.DevProxy.Abstractions; using Microsoft.DevProxy.Plugins.RequestLogs.MinimalPermissions; -using System.CommandLine; -using System.CommandLine.Invocation; using System.IdentityModel.Tokens.Jwt; using System.Net.Http.Json; using System.Text.Json; namespace Microsoft.DevProxy.Plugins.RequestLogs; -internal class MinimalPermissionsGuidancePluginConfiguration +public class MinimalPermissionsGuidancePluginReport { - public string FilePath { get; set; } = ""; + public MinimalPermissionsInfo? DelegatedPermissions { get; set; } + public MinimalPermissionsInfo? ApplicationPermissions { get; set; } } -internal class OperationInfo +public class OperationInfo { public string Method { get; set; } = string.Empty; public string Endpoint { get; set; } = string.Empty; } -internal class MinimalPermissionsInfo +public class MinimalPermissionsInfo { public string[] MinimalPermissions { get; set; } = Array.Empty(); public string[] PermissionsFromTheToken { get; set; } = Array.Empty(); @@ -32,42 +31,9 @@ internal class MinimalPermissionsInfo public OperationInfo[] Operations { get; set; } = Array.Empty(); } -public class MinimalPermissionsGuidancePlugin : BaseProxyPlugin +public class MinimalPermissionsGuidancePlugin : BaseReportingPlugin { public override string Name => nameof(MinimalPermissionsGuidancePlugin); - private MinimalPermissionsGuidancePluginConfiguration _configuration = new(); - private static readonly string _filePathOptionName = "--minimal-permissions-summary-file-path"; - - public override Option[] GetOptions() - { - var filePath = new Option(_filePathOptionName, "Path to the file where the permissions summary should be saved. If not specified, the summary will be printed to the console. Path can be absolute or relative to the current working directory.") - { - ArgumentHelpName = "minimal-permissions-summary-file-path" - }; - filePath.AddValidator(input => - { - var outputFilePath = input.Tokens.First().Value; - if (string.IsNullOrEmpty(outputFilePath)) - { - return; - } - - var dirName = Path.GetDirectoryName(outputFilePath); - if (string.IsNullOrEmpty(dirName)) - { - // current directory exists so no need to check - return; - } - - var outputDir = Path.GetFullPath(dirName); - if (!Directory.Exists(outputDir)) - { - input.ErrorMessage = $"The directory {outputDir} does not exist."; - } - }); - - return [filePath]; - } public override void Register(IPluginEvents pluginEvents, IProxyContext context, @@ -76,23 +42,9 @@ public override void Register(IPluginEvents pluginEvents, { base.Register(pluginEvents, context, urlsToWatch, configSection); - configSection?.Bind(_configuration); - - pluginEvents.OptionsLoaded += OptionsLoaded; pluginEvents.AfterRecordingStop += AfterRecordingStop; } - private void OptionsLoaded(object? sender, OptionsLoadedArgs e) - { - InvocationContext context = e.Context; - - var filePath = context.ParseResult.GetValueForOption(_filePathOptionName, e.Options); - if (filePath is not null) - { - _configuration.FilePath = filePath; - } - } - private async Task AfterRecordingStop(object? sender, RecordingArgs e) { if (!e.RequestLogs.Any()) @@ -157,10 +109,10 @@ private async Task AfterRecordingStop(object? sender, RecordingArgs e) // // application permissions are always the same because they come from app reg // so we can just use the first request that has them - if (scopesAndType.Item2.Length > 0 && + if (scopesAndType.permissions.Length > 0 && rolesToEvaluate.Length == 0) { - rolesToEvaluate = scopesAndType.Item2; + rolesToEvaluate = scopesAndType.permissions; if (ProxyUtils.IsGraphBatchUrl(uri)) { @@ -183,22 +135,16 @@ private async Task AfterRecordingStop(object? sender, RecordingArgs e) return; } - var minimalPermissionsInfo = new List(); + var report = new MinimalPermissionsGuidancePluginReport(); - if (string.IsNullOrEmpty(_configuration.FilePath)) - { - _logger?.LogWarning("This plugin is in preview and may not return the correct results.\r\nPlease review the permissions and test your app before using them in production.\r\nIf you have any feedback, please open an issue at https://aka.ms/devproxy/issue.\r\n"); - } + _logger?.LogWarning("This plugin is in preview and may not return the correct results.\r\nPlease review the permissions and test your app before using them in production.\r\nIf you have any feedback, please open an issue at https://aka.ms/devproxy/issue.\r\n"); if (delegatedEndpoints.Count > 0) { var delegatedPermissionsInfo = new MinimalPermissionsInfo(); - minimalPermissionsInfo.Add(delegatedPermissionsInfo); + report.DelegatedPermissions = delegatedPermissionsInfo; - if (string.IsNullOrEmpty(_configuration.FilePath)) - { - _logger?.LogInformation("Evaluating delegated permissions for:\r\n{endpoints}\r\n", string.Join(Environment.NewLine, delegatedEndpoints.Select(e => $"- {e.method} {e.url}"))); - } + _logger?.LogInformation("Evaluating delegated permissions for:\r\n{endpoints}\r\n", string.Join(Environment.NewLine, delegatedEndpoints.Select(e => $"- {e.method} {e.url}"))); await EvaluateMinimalScopes(delegatedEndpoints, scopesToEvaluate, PermissionsType.Delegated, delegatedPermissionsInfo); } @@ -206,21 +152,14 @@ private async Task AfterRecordingStop(object? sender, RecordingArgs e) if (applicationEndpoints.Count > 0) { var applicationPermissionsInfo = new MinimalPermissionsInfo(); - minimalPermissionsInfo.Add(applicationPermissionsInfo); + report.ApplicationPermissions = applicationPermissionsInfo; - if (string.IsNullOrEmpty(_configuration.FilePath)) - { - _logger?.LogInformation("Evaluating application permissions for:\r\n{applicationPermissions}\r\n", string.Join(Environment.NewLine, applicationEndpoints.Select(e => $"- {e.method} {e.url}"))); - } + _logger?.LogInformation("Evaluating application permissions for:\r\n{applicationPermissions}\r\n", string.Join(Environment.NewLine, applicationEndpoints.Select(e => $"- {e.method} {e.url}"))); await EvaluateMinimalScopes(applicationEndpoints, rolesToEvaluate, PermissionsType.Application, applicationPermissionsInfo); } - if (!string.IsNullOrEmpty(_configuration.FilePath)) - { - var json = JsonSerializer.Serialize(minimalPermissionsInfo, ProxyUtils.JsonSerializerOptions); - await File.WriteAllTextAsync(_configuration.FilePath, json); - } + StoreReport(report, e); } private (string method, string url)[] GetRequestsFromBatch(string batchBody, string graphVersion, string graphHostName) @@ -356,20 +295,6 @@ private async Task EvaluateMinimalScopes(IEnumerable<(string method, string url) permissionsInfo.MinimalPermissions = minimalPermissions; permissionsInfo.ExcessPermissions = excessPermissions; - - if (string.IsNullOrEmpty(_configuration.FilePath)) - { - _logger?.LogInformation("Minimal permissions:\r\n{minimalPermissions}\r\nPermissions on the token:\r\n{tokenPermissions}", string.Join(", ", minimalPermissions), string.Join(", ", permissionsFromAccessToken)); - - if (excessPermissions.Any()) - { - _logger?.LogWarning("The following permissions are unnecessary: {permissions}", string.Join(", ", excessPermissions)); - } - else - { - _logger?.LogInformation("The token has the minimal permissions required."); - } - } } if (errors.Any()) { diff --git a/dev-proxy-plugins/RequestLogs/MinimalPermissionsPlugin.cs b/dev-proxy-plugins/RequestLogs/MinimalPermissionsPlugin.cs index ad5af1af..01c2e44d 100644 --- a/dev-proxy-plugins/RequestLogs/MinimalPermissionsPlugin.cs +++ b/dev-proxy-plugins/RequestLogs/MinimalPermissionsPlugin.cs @@ -11,12 +11,21 @@ namespace Microsoft.DevProxy.Plugins.RequestLogs; +public class MinimalPermissionsPluginReport +{ + public required RequestInfo[] Requests { get; init; } + [JsonConverter(typeof(JsonStringEnumConverter))] + public required PermissionsType PermissionsType { get; init; } + public required string[] MinimalPermissions { get; init; } + public required string[] Errors { get; init; } +} + internal class MinimalPermissionsPluginConfiguration { public PermissionsType Type { get; set; } = PermissionsType.Delegated; } -public class MinimalPermissionsPlugin : BaseProxyPlugin +public class MinimalPermissionsPlugin : BaseReportingPlugin { public override string Name => nameof(MinimalPermissionsPlugin); private MinimalPermissionsPluginConfiguration _configuration = new(); @@ -32,6 +41,7 @@ public override void Register(IPluginEvents pluginEvents, pluginEvents.AfterRecordingStop += AfterRecordingStop; } + private async Task AfterRecordingStop(object? sender, RecordingArgs e) { if (!e.RequestLogs.Any()) @@ -52,7 +62,7 @@ private async Task AfterRecordingStop(object? sender, RecordingArgs e) var methodAndUrlString = request.MessageLines.First(); var methodAndUrl = GetMethodAndUrl(methodAndUrlString); - var uri = new Uri(methodAndUrl.Item2); + var uri = new Uri(methodAndUrl.url); if (!ProxyUtils.IsGraphUrl(uri)) { continue; @@ -82,10 +92,13 @@ private async Task AfterRecordingStop(object? sender, RecordingArgs e) _logger?.LogInformation("Retrieving minimal permissions for:\r\n{endpoints}\r\n", string.Join(Environment.NewLine, endpoints.Select(e => $"- {e.method} {e.url}"))); + _logger?.LogWarning("This plugin is in preview and may not return the correct results.\r\nPlease review the permissions and test your app before using them in production.\r\nIf you have any feedback, please open an issue at https://aka.ms/devproxy/issue.\r\n"); - _logger?.LogWarning("This plugin is in preview and may not return the correct results.\r\nPlease review the permissions and test your app before using them in production.\r\nIf you have any feedback, please open an issue at https://aka.ms/devproxy/issue."); - - await DetermineMinimalScopes(endpoints); + var report = await DetermineMinimalScopes(endpoints); + if (report is not null) + { + StoreReport(report, e); + } } private (string method, string url)[] GetRequestsFromBatch(string batchBody, string graphVersion, string graphHostName) @@ -132,7 +145,7 @@ private string GetScopeTypeString() }; } - private async Task DetermineMinimalScopes(IEnumerable<(string method, string url)> endpoints) + private async Task DetermineMinimalScopes(IEnumerable<(string method, string url)> endpoints) { var payload = endpoints.Select(e => new RequestInfo { Method = e.method, Url = e.url }); @@ -151,19 +164,28 @@ private async Task DetermineMinimalScopes(IEnumerable<(string method, string url var resultsAndErrors = JsonSerializer.Deserialize(content, ProxyUtils.JsonSerializerOptions); var minimalScopes = resultsAndErrors?.Results?.Select(p => p.Value).ToArray() ?? Array.Empty(); var errors = resultsAndErrors?.Errors?.Select(e => $"- {e.Url} ({e.Message})") ?? Array.Empty(); + if (minimalScopes.Any()) { _logger?.LogInformation("Minimal permissions:\r\n{permissions}", string.Join(", ", minimalScopes)); } if (errors.Any()) { - _logger?.LogError("Couldn't determine minimal permissions for the following URLs:\r\n{errors}", string.Join(Environment.NewLine, errors)); } + + return new MinimalPermissionsPluginReport + { + Requests = payload.ToArray(), + PermissionsType = _configuration.Type, + MinimalPermissions = minimalScopes, + Errors = errors.ToArray() + }; } catch (Exception ex) { _logger?.LogError(ex, "An error has occurred while retrieving minimal permissions:"); + return null; } } diff --git a/dev-proxy-plugins/RequestLogs/MockGeneratorPlugin.cs b/dev-proxy-plugins/RequestLogs/MockGeneratorPlugin.cs index 0e5abea7..4b576518 100644 --- a/dev-proxy-plugins/RequestLogs/MockGeneratorPlugin.cs +++ b/dev-proxy-plugins/RequestLogs/MockGeneratorPlugin.cs @@ -10,7 +10,7 @@ namespace Microsoft.DevProxy.Plugins.RequestLogs; -public class MockGeneratorPlugin : BaseProxyPlugin +public class MockGeneratorPlugin : BaseReportingPlugin { public override string Name => nameof(MockGeneratorPlugin); @@ -94,6 +94,8 @@ request.Context is null || _logger?.LogInformation("Created mock file {fileName} with {mocksCount} mocks", fileName, mocks.Count); + StoreReport(fileName, e); + return Task.CompletedTask; } diff --git a/dev-proxy-plugins/RequestLogs/OpenApiSpecGeneratorPlugin.cs b/dev-proxy-plugins/RequestLogs/OpenApiSpecGeneratorPlugin.cs index 8be081d1..facca3b9 100644 --- a/dev-proxy-plugins/RequestLogs/OpenApiSpecGeneratorPlugin.cs +++ b/dev-proxy-plugins/RequestLogs/OpenApiSpecGeneratorPlugin.cs @@ -17,6 +17,19 @@ namespace Microsoft.DevProxy.Plugins.RequestLogs; +public class OpenApiSpecGeneratorPluginReportItem +{ + public required string ServerUrl { get; init; } + public required string FileName { get; init; } +} + +public class OpenApiSpecGeneratorPluginReport : List +{ + public OpenApiSpecGeneratorPluginReport() : base() { } + + public OpenApiSpecGeneratorPluginReport(IEnumerable collection) : base(collection) { } +} + class GeneratedByOpenApiExtension : IOpenApiExtension { public void Write(IOpenApiWriter writer, OpenApiSpecVersion specVersion) @@ -28,7 +41,7 @@ public void Write(IOpenApiWriter writer, OpenApiSpecVersion specVersion) } } -public class OpenApiSpecGeneratorPlugin : BaseProxyPlugin +public class OpenApiSpecGeneratorPlugin : BaseReportingPlugin { // from: https://github.com/jonluca/har-to-openapi/blob/0d44409162c0a127cdaccd60b0a270ecd361b829/src/utils/headers.ts private static readonly string[] standardHeaders = @@ -325,11 +338,20 @@ request.Context is null || _logger?.LogDebug(" Writing OpenAPI spec to {fileName}...", fileName); File.WriteAllText(fileName, docString); + generatedOpenApiSpecs.Add(server.Url, fileName); _logger?.LogInformation("Created OpenAPI spec file {fileName}", fileName); } + StoreReport(new OpenApiSpecGeneratorPluginReport( + generatedOpenApiSpecs + .Select(kvp => new OpenApiSpecGeneratorPluginReportItem + { + ServerUrl = kvp.Key, + FileName = kvp.Value + })), e); + // store the generated OpenAPI specs in the global data // for use by other plugins e.GlobalData[GeneratedOpenApiSpecsKey] = generatedOpenApiSpecs; diff --git a/dev-proxy/PluginLoader.cs b/dev-proxy/PluginLoader.cs index 3ef74626..93eacbc3 100644 --- a/dev-proxy/PluginLoader.cs +++ b/dev-proxy/PluginLoader.cs @@ -30,29 +30,39 @@ public PluginLoader(IProxyLogger logger) public PluginLoaderResult LoadPlugins(IPluginEvents pluginEvents, IProxyContext proxyContext) { List plugins = new(); - PluginConfig config = PluginConfig; - List globallyWatchedUrls = PluginConfig.UrlsToWatch.Select(ConvertToRegex).ToList(); - ISet defaultUrlsToWatch = globallyWatchedUrls.ToHashSet(); - string? configFileDirectory = Path.GetDirectoryName(Path.GetFullPath(ProxyUtils.ReplacePathTokens(ProxyHost.ConfigFile))); + var config = PluginConfig; + var globallyWatchedUrls = PluginConfig.UrlsToWatch.Select(ConvertToRegex).ToList(); + var defaultUrlsToWatch = globallyWatchedUrls.ToHashSet(); + var configFileDirectory = Path.GetDirectoryName(Path.GetFullPath(ProxyUtils.ReplacePathTokens(ProxyHost.ConfigFile))); + // key = location + var pluginContexts = new Dictionary(); + if (!string.IsNullOrEmpty(configFileDirectory)) { foreach (PluginReference h in config.Plugins) { if (!h.Enabled) continue; // Load Handler Assembly if enabled - string pluginLocation = Path.GetFullPath(Path.Combine(configFileDirectory, ProxyUtils.ReplacePathTokens(h.PluginPath.Replace('\\', Path.DirectorySeparatorChar)))); - PluginLoadContext pluginLoadContext = new PluginLoadContext(pluginLocation); + var pluginLocation = Path.GetFullPath(Path.Combine(configFileDirectory, ProxyUtils.ReplacePathTokens(h.PluginPath.Replace('\\', Path.DirectorySeparatorChar)))); + + if (!pluginContexts.TryGetValue(pluginLocation, out PluginLoadContext? pluginLoadContext)) + { + pluginLoadContext = new PluginLoadContext(pluginLocation); + pluginContexts.Add(pluginLocation, pluginLoadContext); + } + _logger?.LogDebug("Loading plugin {pluginName} from: {pluginLocation}", h.Name, pluginLocation); - Assembly assembly = pluginLoadContext.LoadFromAssemblyName(new AssemblyName(Path.GetFileNameWithoutExtension(pluginLocation))); - IEnumerable? pluginUrlsList = h.UrlsToWatch?.Select(ConvertToRegex); + var assembly = pluginLoadContext.LoadFromAssemblyName(new AssemblyName(Path.GetFileNameWithoutExtension(pluginLocation))); + var pluginUrlsList = h.UrlsToWatch?.Select(ConvertToRegex); ISet? pluginUrls = null; + if (pluginUrlsList is not null) { pluginUrls = pluginUrlsList.ToHashSet(); globallyWatchedUrls.AddRange(pluginUrlsList); } - // Load Plugins from assembly - IProxyPlugin plugin = CreatePlugin(assembly, h); + + var plugin = CreatePlugin(assembly, h); _logger?.LogDebug("Registering plugin {pluginName}...", plugin.Name); plugin.Register( pluginEvents, @@ -74,7 +84,8 @@ private IProxyPlugin CreatePlugin(Assembly assembly, PluginReference h) { foreach (Type type in assembly.GetTypes()) { - if (typeof(IProxyPlugin).IsAssignableFrom(type)) + if (type.Name == h.Name && + typeof(IProxyPlugin).IsAssignableFrom(type)) { IProxyPlugin? result = Activator.CreateInstance(type) as IProxyPlugin; if (result is not null && result.Name == h.Name) diff --git a/dev-proxy/ProxyEngine.cs b/dev-proxy/ProxyEngine.cs index 246cb99e..4229e20c 100755 --- a/dev-proxy/ProxyEngine.cs +++ b/dev-proxy/ProxyEngine.cs @@ -33,7 +33,9 @@ public class ProxyEngine // lists of hosts to watch extracted from urlsToWatch, // used for deciding which URLs to decrypt for further inspection private ISet _hostsToWatch = new HashSet(); - private Dictionary _globalData = new(); + private Dictionary _globalData = new() { + { ProxyUtils.ReportsKey, new Dictionary() } + }; private bool _isRecording = false; private List _requestLogs = new List(); @@ -285,6 +287,8 @@ await _pluginEvents.RaiseRecordingStopped(new RecordingArgs(clonedLogs) { GlobalData = _globalData }, _exceptionHandler); + + _logger.LogInformation("DONE"); } private void PrintRecordingIndicator()