From f6fbf3667f3076041f13cd762debd62f957ebd7f Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 10:08:32 +0100 Subject: [PATCH 1/7] Support environment variable placeholders in config and data files. Closes #1667 Resolve ${VAR} (and ${env:VAR}) placeholders when loading Dev Proxy config files and plugin data files. Unset variables are left as-is with a warning; $${VAR} escapes a literal placeholder. Legacy @VAR (API Center) and {VAR} (minimal permissions API specs) keep working but log deprecation warnings. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ConfigurationPlaceholderTests.cs | 103 ++ .../EnvironmentVariablePlaceholdersTests.cs | 86 ++ ...ironmentVariableConfigurationExtensions.cs | 147 +++ .../Extensions/YamlConfigurationExtensions.cs | 38 +- DevProxy.Abstractions/Plugins/BaseLoader.cs | 358 +++---- .../Utils/EnvironmentVariablePlaceholders.cs | 165 +++ DevProxy.Plugins/ApiCenter/ApiCenterClient.cs | 402 ++++---- .../Manipulation/RewritesLoader.cs | 91 +- .../MinimalPermissionsGuidancePlugin.cs | 659 ++++++------ .../Reporting/MinimalPermissionsPlugin.cs | 553 +++++----- .../Utils/MinimalPermissionsUtils.cs | 193 ++-- DevProxy.Tests/BaseLoaderPlaceholderTests.cs | 72 ++ .../ConfigurationManagerExtensions.cs | 58 +- DevProxy/Program.cs | 949 +++++++++--------- schemas/v4.0.0/rc.schema.json | 2 +- .../dev-proxy/references/analyze-api-usage.md | 10 +- .../dev-proxy/references/ci-cd-integration.md | 8 +- skills/dev-proxy/references/configuration.md | 22 + 18 files changed, 2301 insertions(+), 1615 deletions(-) create mode 100644 DevProxy.Abstractions.Tests/Extensions/ConfigurationPlaceholderTests.cs create mode 100644 DevProxy.Abstractions.Tests/Utils/EnvironmentVariablePlaceholdersTests.cs create mode 100644 DevProxy.Abstractions/Extensions/EnvironmentVariableConfigurationExtensions.cs create mode 100644 DevProxy.Abstractions/Utils/EnvironmentVariablePlaceholders.cs create mode 100644 DevProxy.Tests/BaseLoaderPlaceholderTests.cs diff --git a/DevProxy.Abstractions.Tests/Extensions/ConfigurationPlaceholderTests.cs b/DevProxy.Abstractions.Tests/Extensions/ConfigurationPlaceholderTests.cs new file mode 100644 index 000000000..f2f963997 --- /dev/null +++ b/DevProxy.Abstractions.Tests/Extensions/ConfigurationPlaceholderTests.cs @@ -0,0 +1,103 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace DevProxy.Abstractions.Tests.Extensions; + +public sealed class ConfigurationPlaceholderTests : IDisposable +{ + private const string OsVariable = "DEVPROXY_TEST_PLACEHOLDER_OS"; + private readonly string _folder = Directory.CreateTempSubdirectory("devproxy-placeholders").FullName; + + public ConfigurationPlaceholderTests() + { + Environment.SetEnvironmentVariable(OsVariable, "from-os"); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(OsVariable, null); + Directory.Delete(_folder, true); + } + + [Fact] + public void AddConfigFile_Json_ResolvesPlaceholders() + { + var path = Write("devproxyrc.json", $$""" + { + "env": { "FROM_SECTION": "section-${{{OsVariable}}}" }, + "plugin": { + "os": "${{{OsVariable}}}", + "alias": "${env:{{OsVariable}}}", + "section": "${FROM_SECTION}", + "cli": "${FROM_CLI}", + "escaped": "$${{{OsVariable}}}", + "missing": "${DEVPROXY_TEST_PLACEHOLDER_MISSING}", + "port": "${PORT}" + } + } + """); + + var (config, unresolved) = Build(path, new Dictionary { ["FROM_CLI"] = "cli", ["PORT"] = "8000" }); + + Assert.Equal("from-os", config["plugin:os"]); + Assert.Equal("from-os", config["plugin:alias"]); + Assert.Equal("section-from-os", config["env:FROM_SECTION"]); + Assert.Equal("section-from-os", config["plugin:section"]); + Assert.Equal("cli", config["plugin:cli"]); + Assert.Equal($"${{{OsVariable}}}", config["plugin:escaped"]); + Assert.Equal("${DEVPROXY_TEST_PLACEHOLDER_MISSING}", config["plugin:missing"]); + Assert.Equal(8000, config.GetValue("plugin:port")); + Assert.Equal(["DEVPROXY_TEST_PLACEHOLDER_MISSING"], unresolved); + } + + [Fact] + public void AddConfigFile_CliVariablesOverrideEnvSection() + { + var path = Write("devproxyrc.json", """ + { "env": { "NAME": "section" }, "value": "${NAME}" } + """); + + var (config, _) = Build(path, new Dictionary { ["NAME"] = "cli" }); + + Assert.Equal("cli", config["value"]); + } + + [Fact] + public void AddConfigFile_Yaml_ResolvesPlaceholders() + { + var path = Write("devproxyrc.yaml", $$""" + env: + FROM_SECTION: section + plugin: + os: ${{{OsVariable}}} + section: "${FROM_SECTION}" + list: + - ${env:{{OsVariable}}} + """); + + var (config, unresolved) = Build(path, null); + + Assert.Equal("from-os", config["plugin:os"]); + Assert.Equal("section", config["plugin:section"]); + Assert.Equal("from-os", config["plugin:list:0"]); + Assert.Empty(unresolved); + } + + private string Write(string name, string contents) + { + var path = Path.Combine(_folder, name); + File.WriteAllText(path, contents); + return path; + } + + private static (IConfigurationRoot Config, IReadOnlyCollection Unresolved) Build(string path, IReadOnlyDictionary? variables) + { + var config = new ConfigurationBuilder().AddConfigFile(path, environmentVariables: variables).Build(); + var provider = Assert.Single(config.Providers.OfType()); + return (config, provider.UnresolvedEnvironmentVariables); + } +} diff --git a/DevProxy.Abstractions.Tests/Utils/EnvironmentVariablePlaceholdersTests.cs b/DevProxy.Abstractions.Tests/Utils/EnvironmentVariablePlaceholdersTests.cs new file mode 100644 index 000000000..edc5139d3 --- /dev/null +++ b/DevProxy.Abstractions.Tests/Utils/EnvironmentVariablePlaceholdersTests.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Utils; +using System.Text.Json; +using Xunit; + +namespace DevProxy.Abstractions.Tests.Utils; + +public class EnvironmentVariablePlaceholdersTests +{ + private static readonly Dictionary Variables = new() + { + ["TENANT_ID"] = "contoso", + ["CLIENT_ID"] = "1234", + ["QUOTED"] = "a \"quoted\" \\ value" + }; + + private static string? Lookup(string name) => Variables.TryGetValue(name, out var value) ? value : null; + + [Theory] + [InlineData("${TENANT_ID}", "contoso")] + [InlineData("${env:TENANT_ID}", "contoso")] + [InlineData("https://login/${TENANT_ID}/v2.0", "https://login/contoso/v2.0")] + [InlineData("${TENANT_ID}${CLIENT_ID}", "contoso1234")] + [InlineData("$${TENANT_ID}", "${TENANT_ID}")] + [InlineData("$${env:TENANT_ID}", "${env:TENANT_ID}")] + [InlineData("no placeholders", "no placeholders")] + [InlineData("{TENANT_ID} @TENANT_ID", "{TENANT_ID} @TENANT_ID")] + [InlineData("${not a var}", "${not a var}")] + [InlineData("$TENANT_ID", "$TENANT_ID")] + public void Resolve_ReplacesPlaceholders(string input, string expected) => + Assert.Equal(expected, EnvironmentVariablePlaceholders.Resolve(input, Lookup)); + + [Fact] + public void Resolve_LeavesUnsetVariablesAndReportsThemOnce() + { + var unresolved = new List(); + + var result = EnvironmentVariablePlaceholders.Resolve("${MISSING}/${TENANT_ID}/${env:MISSING}", Lookup, unresolved); + + Assert.Equal("${MISSING}/contoso/${env:MISSING}", result); + Assert.Equal(["MISSING"], unresolved); + } + + [Fact] + public void Resolve_DoesNotReportEscapedPlaceholders() + { + var unresolved = new List(); + + _ = EnvironmentVariablePlaceholders.Resolve("$${MISSING}", Lookup, unresolved); + + Assert.Empty(unresolved); + } + + [Fact] + public void ResolveInJson_ResolvesStringValuesOnly() + { + var json = """ + { + // comment + "${TENANT_ID}": "${TENANT_ID}", + "nested": { "items": ["${CLIENT_ID}", 1, true, null] }, + "quoted": "${QUOTED}", + } + """; + + var result = EnvironmentVariablePlaceholders.ResolveInJson(json, Lookup); + + using var doc = JsonDocument.Parse(result); + var root = doc.RootElement; + Assert.Equal("contoso", root.GetProperty("${TENANT_ID}").GetString()); + Assert.Equal("1234", root.GetProperty("nested").GetProperty("items")[0].GetString()); + Assert.Equal(1, root.GetProperty("nested").GetProperty("items")[1].GetInt32()); + Assert.Equal("a \"quoted\" \\ value", root.GetProperty("quoted").GetString()); + } + + [Fact] + public void ResolveInJson_ReturnsOriginalWhenNoPlaceholders() + { + var json = "{ /* keep */ \"a\": \"b\" }"; + + Assert.Same(json, EnvironmentVariablePlaceholders.ResolveInJson(json, Lookup)); + } +} diff --git a/DevProxy.Abstractions/Extensions/EnvironmentVariableConfigurationExtensions.cs b/DevProxy.Abstractions/Extensions/EnvironmentVariableConfigurationExtensions.cs new file mode 100644 index 000000000..688e1b2a2 --- /dev/null +++ b/DevProxy.Abstractions/Extensions/EnvironmentVariableConfigurationExtensions.cs @@ -0,0 +1,147 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Utils; +using Microsoft.Extensions.Configuration.Json; + +#pragma warning disable IDE0130 +namespace Microsoft.Extensions.Configuration; +#pragma warning restore IDE0130 + +/// +/// A configuration provider that resolves environment variable placeholders +/// (${NAME}, ${env:NAME}) in configuration values. +/// +public interface IEnvironmentVariablePlaceholderProvider +{ + /// + /// Path of the configuration file. + /// + string? Path { get; } + + /// + /// Names of environment variables referenced in the configuration file that couldn't be resolved during the last load. + /// + IReadOnlyCollection UnresolvedEnvironmentVariables { get; } +} + +/// +/// JSON configuration source that resolves environment variable placeholders. +/// +public sealed class EnvironmentVariableJsonConfigurationSource : JsonConfigurationSource +{ + /// + /// Variables that take precedence over the env configuration section and process environment variables. + /// + public IReadOnlyDictionary? EnvironmentVariables { get; set; } + + /// + public override IConfigurationProvider Build(IConfigurationBuilder builder) + { + EnsureDefaults(builder); + return new EnvironmentVariableJsonConfigurationProvider(this); + } +} + +/// +/// JSON configuration provider that resolves environment variable placeholders. +/// +public sealed class EnvironmentVariableJsonConfigurationProvider(EnvironmentVariableJsonConfigurationSource source) : + JsonConfigurationProvider(source), IEnvironmentVariablePlaceholderProvider +{ + /// + public string? Path => source.Path; + + /// + public IReadOnlyCollection UnresolvedEnvironmentVariables { get; private set; } = []; + + /// + public override void Load(Stream stream) + { + base.Load(stream); + UnresolvedEnvironmentVariables = ConfigurationPlaceholderResolver.Resolve(Data, source.EnvironmentVariables); + } +} + +/// +/// Resolves environment variable placeholders in flattened configuration data. +/// +public static class ConfigurationPlaceholderResolver +{ + private const string EnvSectionPrefix = "env:"; + + private static StringComparer VariableNameComparer => + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + + /// + /// Resolves placeholders in configuration values in place. Variables are looked up in + /// first, then in the env configuration + /// section, and finally in process environment variables. Values in the env + /// section itself are resolved against and + /// process environment variables only. + /// + /// Names of variables that couldn't be resolved. + public static IReadOnlyCollection Resolve(IDictionary data, IReadOnlyDictionary? environmentVariables) + { + ArgumentNullException.ThrowIfNull(data); + + var unresolved = new List(); + var overrides = environmentVariables is null + ? null + : new Dictionary(environmentVariables, VariableNameComparer); + + string? baseLookup(string name) + { + return overrides is not null && overrides.TryGetValue(name, out var value) + ? value + : Environment.GetEnvironmentVariable(name); + } + + var envSection = new Dictionary(VariableNameComparer); + foreach (var key in data.Keys.ToArray()) + { + if (!IsEnvSectionVariable(key)) + { + continue; + } + + var value = data[key]; + if (value is not null) + { + value = EnvironmentVariablePlaceholders.Resolve(value, baseLookup, unresolved); + data[key] = value; + envSection[key[EnvSectionPrefix.Length..]] = value; + } + } + + string? lookup(string name) + { + if (overrides is not null && overrides.TryGetValue(name, out var value)) + { + return value; + } + return envSection.TryGetValue(name, out value) ? value : Environment.GetEnvironmentVariable(name); + } + + foreach (var key in data.Keys.ToArray()) + { + if (IsEnvSectionVariable(key)) + { + continue; + } + + var value = data[key]; + if (value is not null) + { + data[key] = EnvironmentVariablePlaceholders.Resolve(value, lookup, unresolved); + } + } + + return unresolved; + } + + private static bool IsEnvSectionVariable(string key) => + key.StartsWith(EnvSectionPrefix, StringComparison.OrdinalIgnoreCase) && + key.IndexOf(ConfigurationPath.KeyDelimiter, EnvSectionPrefix.Length, StringComparison.Ordinal) < 0; +} diff --git a/DevProxy.Abstractions/Extensions/YamlConfigurationExtensions.cs b/DevProxy.Abstractions/Extensions/YamlConfigurationExtensions.cs index feb5ab0c1..59d743c0c 100644 --- a/DevProxy.Abstractions/Extensions/YamlConfigurationExtensions.cs +++ b/DevProxy.Abstractions/Extensions/YamlConfigurationExtensions.cs @@ -14,6 +14,12 @@ namespace Microsoft.Extensions.Configuration; /// public sealed class YamlConfigurationSource : FileConfigurationSource { + /// + /// Variables used to resolve environment variable placeholders. They take precedence over + /// the env configuration section and process environment variables. + /// + public IReadOnlyDictionary? EnvironmentVariables { get; set; } + /// public override IConfigurationProvider Build(IConfigurationBuilder builder) { @@ -25,8 +31,14 @@ public override IConfigurationProvider Build(IConfigurationBuilder builder) /// /// A YAML file configuration provider that supports anchors and merge keys. /// -public sealed class YamlConfigurationProvider : FileConfigurationProvider +public sealed class YamlConfigurationProvider : FileConfigurationProvider, IEnvironmentVariablePlaceholderProvider { + /// + public string? Path => Source.Path; + + /// + public IReadOnlyCollection UnresolvedEnvironmentVariables { get; private set; } = []; + /// /// Initializes a new instance of the class. /// @@ -57,6 +69,8 @@ public override void Load(Stream stream) { FlattenYamlNode(mappingNode, string.Empty); } + + UnresolvedEnvironmentVariables = ConfigurationPlaceholderResolver.Resolve(Data, ((YamlConfigurationSource)Source).EnvironmentVariables); } private void FlattenYamlNode(YamlNode node, string prefix) @@ -250,12 +264,14 @@ public static class YamlConfigurationExtensions /// The path to the YAML file. /// Whether the file is optional. /// Whether to reload on change. + /// Variables used to resolve ${NAME} placeholders before the env section and process environment variables. /// The configuration builder. public static IConfigurationBuilder AddYamlFile( this IConfigurationBuilder builder, string path, bool optional = false, - bool reloadOnChange = false) + bool reloadOnChange = false, + IReadOnlyDictionary? environmentVariables = null) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(path); @@ -266,6 +282,7 @@ public static IConfigurationBuilder AddYamlFile( s.Path = path; s.Optional = optional; s.ReloadOnChange = reloadOnChange; + s.EnvironmentVariables = environmentVariables; s.ResolveFileProvider(); }); } @@ -277,21 +294,32 @@ public static IConfigurationBuilder AddYamlFile( /// The path to the configuration file. /// Whether the file is optional. /// Whether to reload on change. + /// Variables used to resolve ${NAME} placeholders before the env section and process environment variables. /// The configuration builder. + /// Environment variable placeholders (${NAME}, ${env:NAME}) in configuration values are resolved when the file is loaded. public static IConfigurationBuilder AddConfigFile( this IConfigurationBuilder builder, string path, bool optional = false, - bool reloadOnChange = false) + bool reloadOnChange = false, + IReadOnlyDictionary? environmentVariables = null) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(path); if (ProxyYaml.IsYamlFile(path)) { - return builder.AddYamlFile(path, optional, reloadOnChange); + return builder.AddYamlFile(path, optional, reloadOnChange, environmentVariables); } - return builder.AddJsonFile(path, optional, reloadOnChange); + return builder.Add(s => + { + s.FileProvider = null; + s.Path = path; + s.Optional = optional; + s.ReloadOnChange = reloadOnChange; + s.EnvironmentVariables = environmentVariables; + s.ResolveFileProvider(); + }); } } diff --git a/DevProxy.Abstractions/Plugins/BaseLoader.cs b/DevProxy.Abstractions/Plugins/BaseLoader.cs index b06cc255c..b9d210ebe 100644 --- a/DevProxy.Abstractions/Plugins/BaseLoader.cs +++ b/DevProxy.Abstractions/Plugins/BaseLoader.cs @@ -1,174 +1,186 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using DevProxy.Abstractions.Proxy; -using DevProxy.Abstractions.Utils; -using Microsoft.Extensions.Logging; -using System.Text.Json; - -namespace DevProxy.Abstractions.Plugins; - -public abstract class BaseLoader(HttpClient httpClient, ILogger logger, IProxyConfiguration proxyConfiguration) : IDisposable -{ - private CancellationToken? _cancellationToken; -#pragma warning disable CA2213 // HttpClient is injected, so we don't dispose it - private readonly HttpClient _httpClient = httpClient; -#pragma warning restore CA2213 - private readonly bool _validateSchemas = proxyConfiguration.ValidateSchemas; - private readonly Lock _debounceLock = new(); - private readonly int _debounceDelay = 300; // milliseconds - - private FileSystemWatcher? _watcher; - private Timer? _debounceTimer; - private volatile bool _isDisposed; - - protected abstract string FilePath { get; } - protected ILogger Logger { get; } = logger; - - public async Task InitFileWatcherAsync(CancellationToken cancellationToken) - { - _cancellationToken = cancellationToken; - - if (_watcher is not null) - { - return; - } - - var path = Path.GetDirectoryName(FilePath) ?? throw new InvalidOperationException($"{FilePath} is an invalid path"); - if (!File.Exists(FilePath)) - { - Logger.LogWarning("File {File} not found. No data will be provided", FilePath); - return; - } - - _watcher = new(Path.GetFullPath(path)) - { - NotifyFilter = NotifyFilters.CreationTime - | NotifyFilters.FileName - | NotifyFilters.LastWrite - | NotifyFilters.Size, - Filter = Path.GetFileName(FilePath) - }; - _watcher.Changed += File_Changed; - _watcher.EnableRaisingEvents = true; - - await LoadFileContentsAsync(cancellationToken); - } - - protected abstract void LoadData(string fileContents); - - protected virtual void Dispose(bool disposing) - { - if (_isDisposed) - { - return; - } - - // Set flag first to prevent in-flight timer callbacks from proceeding - _isDisposed = true; - - if (disposing) - { - _watcher?.Dispose(); - _debounceTimer?.Dispose(); - } - } - - private async Task ValidateFileContentsAsync(string fileContents, CancellationToken cancellationToken) - { - using var document = JsonDocument.Parse(fileContents, ProxyUtils.JsonDocumentOptions); - var root = document.RootElement; - - // Schema validation only applies to JSON objects, not arrays - if (root.ValueKind != JsonValueKind.Object || - !root.TryGetProperty("$schema", out var schemaUrlElement)) - { - Logger.LogDebug("Schema reference not found in file {File}. Skipping schema validation", FilePath); - return true; - } - - var schemaUrl = schemaUrlElement.GetString() ?? ""; - ProxyUtils.ValidateSchemaVersion(schemaUrl, Logger); - var (IsValid, ValidationErrors) = await ProxyUtils.ValidateJsonAsync(fileContents, schemaUrl, _httpClient, Logger, cancellationToken); - - if (!IsValid) - { - if (Logger.IsEnabled(LogLevel.Error)) - { - Logger.LogError("Schema validation failed for {File} with the following errors: {Errors}", FilePath, string.Join(", ", ValidationErrors)); - } - } - - return IsValid; - } - - private async Task LoadFileContentsAsync(CancellationToken cancellationToken) - { - if (_isDisposed) - { - return; - } - - if (!File.Exists(FilePath)) - { - Logger.LogWarning("File {File} not found. No data will be loaded", FilePath); - return; - } - - try - { - using var stream = new FileStream(FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - using var reader = new StreamReader(stream); - var fileContents = await reader.ReadToEndAsync(cancellationToken); - - // Convert YAML to JSON if needed - string jsonContents; - if (ProxyYaml.IsYamlFile(FilePath)) - { - if (!ProxyYaml.TryConvertYamlToJson(fileContents, out var converted, out var error)) - { - Logger.LogError("Failed to parse YAML file {File}: {Error}", FilePath, error); - return; - } - jsonContents = converted!; - } - else - { - jsonContents = fileContents; - } - - if (!_validateSchemas || await ValidateFileContentsAsync(jsonContents, cancellationToken)) - { - LoadData(jsonContents); - } - } - catch (Exception ex) - { - Logger.LogError(ex, "An error has occurred while reading {File}:", FilePath); - } - } - - private void File_Changed(object sender, FileSystemEventArgs e) - { - // Don't process file changes if this loader has been disposed - if (_isDisposed) - { - return; - } - - lock (_debounceLock) - { - _debounceTimer?.Dispose(); -#pragma warning disable CS4014 // we don't need to await this - _debounceTimer = new(_ => LoadFileContentsAsync(_cancellationToken ?? CancellationToken.None), null, _debounceDelay, Timeout.Infinite); -#pragma warning restore CS4014 - } - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Utils; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace DevProxy.Abstractions.Plugins; + +public abstract class BaseLoader(HttpClient httpClient, ILogger logger, IProxyConfiguration proxyConfiguration) : IDisposable +{ + private CancellationToken? _cancellationToken; +#pragma warning disable CA2213 // HttpClient is injected, so we don't dispose it + private readonly HttpClient _httpClient = httpClient; +#pragma warning restore CA2213 + private readonly bool _validateSchemas = proxyConfiguration.ValidateSchemas; + private readonly Lock _debounceLock = new(); + private readonly int _debounceDelay = 300; // milliseconds + + private FileSystemWatcher? _watcher; + private Timer? _debounceTimer; + private volatile bool _isDisposed; + + protected abstract string FilePath { get; } + /// + /// Whether to resolve environment variable placeholders (${NAME}, ${env:NAME}) + /// in string values of the loaded file. Defaults to true. + /// + protected virtual bool ResolveEnvironmentVariables => true; + protected ILogger Logger { get; } = logger; + + public async Task InitFileWatcherAsync(CancellationToken cancellationToken) + { + _cancellationToken = cancellationToken; + + if (_watcher is not null) + { + return; + } + + var path = Path.GetDirectoryName(FilePath) ?? throw new InvalidOperationException($"{FilePath} is an invalid path"); + if (!File.Exists(FilePath)) + { + Logger.LogWarning("File {File} not found. No data will be provided", FilePath); + return; + } + + _watcher = new(Path.GetFullPath(path)) + { + NotifyFilter = NotifyFilters.CreationTime + | NotifyFilters.FileName + | NotifyFilters.LastWrite + | NotifyFilters.Size, + Filter = Path.GetFileName(FilePath) + }; + _watcher.Changed += File_Changed; + _watcher.EnableRaisingEvents = true; + + await LoadFileContentsAsync(cancellationToken); + } + + protected abstract void LoadData(string fileContents); + + protected virtual void Dispose(bool disposing) + { + if (_isDisposed) + { + return; + } + + // Set flag first to prevent in-flight timer callbacks from proceeding + _isDisposed = true; + + if (disposing) + { + _watcher?.Dispose(); + _debounceTimer?.Dispose(); + } + } + + private async Task ValidateFileContentsAsync(string fileContents, CancellationToken cancellationToken) + { + using var document = JsonDocument.Parse(fileContents, ProxyUtils.JsonDocumentOptions); + var root = document.RootElement; + + // Schema validation only applies to JSON objects, not arrays + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("$schema", out var schemaUrlElement)) + { + Logger.LogDebug("Schema reference not found in file {File}. Skipping schema validation", FilePath); + return true; + } + + var schemaUrl = schemaUrlElement.GetString() ?? ""; + ProxyUtils.ValidateSchemaVersion(schemaUrl, Logger); + var (IsValid, ValidationErrors) = await ProxyUtils.ValidateJsonAsync(fileContents, schemaUrl, _httpClient, Logger, cancellationToken); + + if (!IsValid) + { + if (Logger.IsEnabled(LogLevel.Error)) + { + Logger.LogError("Schema validation failed for {File} with the following errors: {Errors}", FilePath, string.Join(", ", ValidationErrors)); + } + } + + return IsValid; + } + + private async Task LoadFileContentsAsync(CancellationToken cancellationToken) + { + if (_isDisposed) + { + return; + } + + if (!File.Exists(FilePath)) + { + Logger.LogWarning("File {File} not found. No data will be loaded", FilePath); + return; + } + + try + { + using var stream = new FileStream(FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream); + var fileContents = await reader.ReadToEndAsync(cancellationToken); + + // Convert YAML to JSON if needed + string jsonContents; + if (ProxyYaml.IsYamlFile(FilePath)) + { + if (!ProxyYaml.TryConvertYamlToJson(fileContents, out var converted, out var error)) + { + Logger.LogError("Failed to parse YAML file {File}: {Error}", FilePath, error); + return; + } + jsonContents = converted!; + } + else + { + jsonContents = fileContents; + } + + if (ResolveEnvironmentVariables) + { + var unresolvedVariables = new List(); + jsonContents = EnvironmentVariablePlaceholders.ResolveInJson(jsonContents, unresolvedVariables); + EnvironmentVariablePlaceholders.LogUnresolvedVariables(Logger, unresolvedVariables, FilePath); + } + + if (!_validateSchemas || await ValidateFileContentsAsync(jsonContents, cancellationToken)) + { + LoadData(jsonContents); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "An error has occurred while reading {File}:", FilePath); + } + } + + private void File_Changed(object sender, FileSystemEventArgs e) + { + // Don't process file changes if this loader has been disposed + if (_isDisposed) + { + return; + } + + lock (_debounceLock) + { + _debounceTimer?.Dispose(); +#pragma warning disable CS4014 // we don't need to await this + _debounceTimer = new(_ => LoadFileContentsAsync(_cancellationToken ?? CancellationToken.None), null, _debounceDelay, Timeout.Infinite); +#pragma warning restore CS4014 + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } } \ No newline at end of file diff --git a/DevProxy.Abstractions/Utils/EnvironmentVariablePlaceholders.cs b/DevProxy.Abstractions/Utils/EnvironmentVariablePlaceholders.cs new file mode 100644 index 000000000..9764dd891 --- /dev/null +++ b/DevProxy.Abstractions/Utils/EnvironmentVariablePlaceholders.cs @@ -0,0 +1,165 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Extensions.Logging; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace DevProxy.Abstractions.Utils; + +/// +/// Resolves environment variable placeholders in user-authored files. +/// Supports ${NAME} and ${env:NAME}. Use $${NAME} to +/// output a literal ${NAME}. Placeholders that reference variables +/// that aren't set are left as-is. +/// +public static partial class EnvironmentVariablePlaceholders +{ + private static readonly JsonSerializerOptions _writeOptions = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + /// + /// Returns true if the value contains something that looks like a placeholder or an escaped placeholder. + /// + public static bool ContainsPlaceholder(string? value) => + !string.IsNullOrEmpty(value) && PlaceholderRegex().IsMatch(value); + + /// + /// Resolves placeholders in a string value using process environment variables. + /// + public static string Resolve(string value, ICollection? unresolvedVariables = null) => + Resolve(value, Environment.GetEnvironmentVariable, unresolvedVariables); + + /// + /// Resolves placeholders in a string value using the specified lookup. + /// + /// Value to resolve. + /// Function that returns the value of a variable or null if it isn't set. + /// Collects names of variables that couldn't be resolved. + public static string Resolve(string value, Func lookup, ICollection? unresolvedVariables = null) + { + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(lookup); + + if (!value.Contains("${", StringComparison.Ordinal)) + { + return value; + } + + return PlaceholderRegex().Replace(value, match => + { + var name = match.Groups["name"].Value; + if (match.Groups["escape"].Success) + { + // $${NAME} -> literal ${NAME} + return match.Value[1..]; + } + + var variableValue = lookup(name); + if (variableValue is null) + { + if (unresolvedVariables is not null && !unresolvedVariables.Contains(name)) + { + unresolvedVariables.Add(name); + } + return match.Value; + } + + return variableValue; + }); + } + + /// + /// Resolves placeholders in all string values of a JSON document. Property names aren't changed. + /// Returns the original string if it doesn't contain any placeholders. + /// + public static string ResolveInJson(string json, Func lookup, ICollection? unresolvedVariables = null) + { + ArgumentNullException.ThrowIfNull(json); + ArgumentNullException.ThrowIfNull(lookup); + + if (!ContainsPlaceholder(json)) + { + return json; + } + + var node = JsonNode.Parse(json, documentOptions: ProxyUtils.JsonDocumentOptions); + if (node is null) + { + return json; + } + + node = ResolveInJsonNode(node, lookup, unresolvedVariables); + return node.ToJsonString(_writeOptions); + } + + /// + /// Resolves placeholders in all string values of a JSON document using process environment variables. + /// + public static string ResolveInJson(string json, ICollection? unresolvedVariables = null) => + ResolveInJson(json, Environment.GetEnvironmentVariable, unresolvedVariables); + + /// + /// Logs a warning for each environment variable that couldn't be resolved. + /// + public static void LogUnresolvedVariables(ILogger logger, IEnumerable unresolvedVariables, string source) + { + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(unresolvedVariables); + + foreach (var name in unresolvedVariables) + { + logger.LogWarning( + "Environment variable {Name} referenced in {Source} isn't set. Leaving the placeholder as-is", + name, source); + } + } + + private static JsonNode ResolveInJsonNode(JsonNode node, Func lookup, ICollection? unresolvedVariables) + { + switch (node) + { + case JsonObject obj: + foreach (var property in obj.ToArray()) + { + if (property.Value is not null) + { + var resolved = ResolveInJsonNode(property.Value, lookup, unresolvedVariables); + if (!ReferenceEquals(resolved, property.Value)) + { + obj[property.Key] = resolved; + } + } + } + return obj; + case JsonArray array: + for (var i = 0; i < array.Count; i++) + { + var item = array[i]; + if (item is not null) + { + var resolved = ResolveInJsonNode(item, lookup, unresolvedVariables); + if (!ReferenceEquals(resolved, item)) + { + array[i] = resolved; + } + } + } + return array; + case JsonValue value when value.GetValueKind() == JsonValueKind.String: + var s = value.GetValue(); + var r = Resolve(s, lookup, unresolvedVariables); + return string.Equals(s, r, StringComparison.Ordinal) ? value : JsonValue.Create(r); + default: + return node; + } + } + + [GeneratedRegex(@"(?\$)?\$\{(?:env:)?(?[A-Za-z_][A-Za-z0-9_]*)\}")] + private static partial Regex PlaceholderRegex(); +} diff --git a/DevProxy.Plugins/ApiCenter/ApiCenterClient.cs b/DevProxy.Plugins/ApiCenter/ApiCenterClient.cs index 7cb5fd027..ed843e5f6 100644 --- a/DevProxy.Plugins/ApiCenter/ApiCenterClient.cs +++ b/DevProxy.Plugins/ApiCenter/ApiCenterClient.cs @@ -1,200 +1,204 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using Azure.Core; -using Azure.Core.Diagnostics; -using Azure.Identity; -using DevProxy.Abstractions.Utils; -using DevProxy.Plugins.Handlers; -using DevProxy.Plugins.Models.ApiCenter; -using Microsoft.Extensions.Logging; -using System.Diagnostics.Tracing; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json; - -namespace DevProxy.Plugins.ApiCenter; - -internal sealed class ApiCenterClientConfiguration -{ - public string ResourceGroupName { get; set; } = ""; - public string ServiceName { get; set; } = ""; - public string SubscriptionId { get; set; } = ""; - public string WorkspaceName { get; set; } = "default"; -} - -internal sealed class ApiCenterClient : IDisposable -{ - private readonly AuthenticationDelegatingHandler _authenticationHandler; - private readonly ApiCenterClientConfiguration _configuration; - private readonly TokenCredential _credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions() - { - ExcludeInteractiveBrowserCredential = true - }); - private readonly HttpClient _httpClient; - private readonly ILogger _logger; - private readonly string[] _scopes = ["https://management.azure.com/.default"]; - - internal ApiCenterClient(ApiCenterClientConfiguration configuration, ILogger logger) - { - if (string.IsNullOrEmpty(configuration.SubscriptionId)) - { - throw new ArgumentException("Specify subscriptionId in the configuration."); - } - if (string.IsNullOrEmpty(configuration.ResourceGroupName)) - { - throw new ArgumentException("Specify resourceGroupName in the configuration."); - } - if (string.IsNullOrEmpty(configuration.ServiceName)) - { - throw new ArgumentException("Specify serviceName in the configuration."); - } - if (string.IsNullOrEmpty(configuration.WorkspaceName)) - { - throw new ArgumentException("Specify workspaceName in the configuration."); - } - - // load configuration from env vars - configuration.SubscriptionId = ResolveEnvironmentVariable(configuration.SubscriptionId, "subscriptionId"); - configuration.ResourceGroupName = ResolveEnvironmentVariable(configuration.ResourceGroupName, "resourceGroupName"); - configuration.ServiceName = ResolveEnvironmentVariable(configuration.ServiceName, "serviceName"); - configuration.WorkspaceName = ResolveEnvironmentVariable(configuration.WorkspaceName, "workspaceName"); - - _configuration = configuration; - _logger = logger; - - _authenticationHandler = new(_credential, _scopes) - { - InnerHandler = new TracingDelegatingHandler(logger) - { - InnerHandler = new HttpClientHandler() - } - }; - _httpClient = new HttpClient(_authenticationHandler); - - if (_logger.IsEnabled(LogLevel.Debug)) - { - using var azureLogger = AzureEventSourceListener.CreateConsoleLogger(EventLevel.Verbose); - } - } - - private static string ResolveEnvironmentVariable(string value, string settingName) - { - if (!value.StartsWith('@')) - { - return value; - } - - var variableName = value[1..]; - var variableValue = Environment.GetEnvironmentVariable(variableName); - return string.IsNullOrEmpty(variableValue) - ? throw new ArgumentException($"Environment variable {variableName} referenced by {settingName} isn't set.") - : variableValue; - } - - internal Task GetAccessTokenAsync(CancellationToken cancellationToken) => - _authenticationHandler.GetAccessTokenAsync(cancellationToken); - - internal async Task GetApisAsync() - { - _logger.LogInformation("Loading APIs from API Center..."); - - var res = await _httpClient.GetStringAsync($"https://management.azure.com/subscriptions/{_configuration.SubscriptionId}/resourceGroups/{_configuration.ResourceGroupName}/providers/Microsoft.ApiCenter/services/{_configuration.ServiceName}/workspaces/{_configuration.WorkspaceName}/apis?api-version=2024-03-01"); - var collection = JsonSerializer.Deserialize>(res, ProxyUtils.JsonSerializerOptions); - return collection?.Value; - } - - internal async Task GetDefinitionAsync(string definitionId, CancellationToken cancellationToken) - { - _logger.LogDebug("Loading API definition {Id}...", definitionId); - - var res = await _httpClient.GetStringAsync($"https://management.azure.com{definitionId}?api-version=2024-03-01", cancellationToken); - return JsonSerializer.Deserialize(res, ProxyUtils.JsonSerializerOptions); - } - - internal async Task GetDefinitionsAsync(string versionId, CancellationToken cancellationToken) - { - _logger.LogDebug("Loading API definitions for version {Id}...", versionId); - - var res = await _httpClient.GetStringAsync($"https://management.azure.com{versionId}/definitions?api-version=2024-03-01", cancellationToken); - var collection = JsonSerializer.Deserialize>(res, ProxyUtils.JsonSerializerOptions); - return collection?.Value; - } - - internal async Task GetDeploymentsAsync(string apiId, CancellationToken cancellationToken) - { - _logger.LogDebug("Loading API deployments for {ApiName}...", apiId); - - var res = await _httpClient.GetStringAsync($"https://management.azure.com{apiId}/deployments?api-version=2024-03-01", cancellationToken); - var collection = JsonSerializer.Deserialize>(res, ProxyUtils.JsonSerializerOptions); - return collection?.Value; - } - - internal async Task GetVersionsAsync(string apiId, CancellationToken cancellationToken) - { - _logger.LogDebug("Loading API versions for {ApiName}...", apiId); - - var res = await _httpClient.GetStringAsync($"https://management.azure.com{apiId}/versions?api-version=2024-03-01", cancellationToken); - var collection = JsonSerializer.Deserialize>(res, ProxyUtils.JsonSerializerOptions); - return collection?.Value; - } - - internal async Task PostExportSpecificationAsync(string definitionId, CancellationToken cancellationToken) - { - var definitionRes = await _httpClient.PostAsync($"https://management.azure.com{definitionId}/exportSpecification?api-version=2024-03-01", null, cancellationToken); - return await definitionRes.Content.ReadFromJsonAsync(ProxyUtils.JsonSerializerOptions, cancellationToken); - } - - internal async Task PostImportSpecificationAsync(ApiSpecImport apiSpecImport, string definitionId, CancellationToken cancellationToken) - { - using var content = new StringContent(JsonSerializer.Serialize(apiSpecImport, ProxyUtils.JsonSerializerOptions), Encoding.UTF8, "application/json"); - return await _httpClient.PostAsync($"https://management.azure.com{definitionId}/importSpecification?api-version=2024-03-01", content, cancellationToken); - } - - internal async Task PutApiAsync(Api api, string apiName, CancellationToken cancellationToken) - { - using var content = new StringContent(JsonSerializer.Serialize(api, ProxyUtils.JsonSerializerOptions), Encoding.UTF8, "application/json"); - var res = await _httpClient.PutAsync($"https://management.azure.com/subscriptions/{_configuration.SubscriptionId}/resourceGroups/{_configuration.ResourceGroupName}/providers/Microsoft.ApiCenter/services/{_configuration.ServiceName}/workspaces/{_configuration.WorkspaceName}/apis/{apiName}?api-version=2024-03-01", content, cancellationToken); - - var resContent = await res.Content.ReadAsStringAsync(cancellationToken); - _logger.LogDebug("{Response}", resContent); - - return res.IsSuccessStatusCode ? - JsonSerializer.Deserialize(resContent, ProxyUtils.JsonSerializerOptions) : - null; - } - - internal async Task PutVersionAsync(ApiVersion apiVersion, string apiId, string apiName, CancellationToken cancellationToken) - { - using var content = new StringContent(JsonSerializer.Serialize(apiVersion, ProxyUtils.JsonSerializerOptions), Encoding.UTF8, "application/json"); - var res = await _httpClient.PutAsync($"https://management.azure.com{apiId}/versions/{apiName}?api-version=2024-03-01", content, cancellationToken); - - var resContent = await res.Content.ReadAsStringAsync(cancellationToken); - _logger.LogDebug("{Response}", resContent); - - return res.IsSuccessStatusCode ? - JsonSerializer.Deserialize(resContent, ProxyUtils.JsonSerializerOptions) : - null; - } - - internal async Task PutDefinitionAsync(ApiDefinition apiDefinition, string apiVersionId, string definitionName, CancellationToken cancellationToken) - { - using var content = new StringContent(JsonSerializer.Serialize(apiDefinition, ProxyUtils.JsonSerializerOptions), Encoding.UTF8, "application/json"); - var res = await _httpClient.PutAsync($"https://management.azure.com{apiVersionId}/definitions/{definitionName}?api-version=2024-03-01", content, cancellationToken); - - var resContent = await res.Content.ReadAsStringAsync(cancellationToken); - _logger.LogDebug("{Response}", resContent); - - return res.IsSuccessStatusCode ? - JsonSerializer.Deserialize(resContent, ProxyUtils.JsonSerializerOptions) : - null; - } - - public void Dispose() - { - _httpClient.Dispose(); - _authenticationHandler.Dispose(); - GC.SuppressFinalize(this); - } +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Azure.Core; +using Azure.Core.Diagnostics; +using Azure.Identity; +using DevProxy.Abstractions.Utils; +using DevProxy.Plugins.Handlers; +using DevProxy.Plugins.Models.ApiCenter; +using Microsoft.Extensions.Logging; +using System.Diagnostics.Tracing; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; + +namespace DevProxy.Plugins.ApiCenter; + +internal sealed class ApiCenterClientConfiguration +{ + public string ResourceGroupName { get; set; } = ""; + public string ServiceName { get; set; } = ""; + public string SubscriptionId { get; set; } = ""; + public string WorkspaceName { get; set; } = "default"; +} + +internal sealed class ApiCenterClient : IDisposable +{ + private readonly AuthenticationDelegatingHandler _authenticationHandler; + private readonly ApiCenterClientConfiguration _configuration; + private readonly TokenCredential _credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions() + { + ExcludeInteractiveBrowserCredential = true + }); + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly string[] _scopes = ["https://management.azure.com/.default"]; + + internal ApiCenterClient(ApiCenterClientConfiguration configuration, ILogger logger) + { + if (string.IsNullOrEmpty(configuration.SubscriptionId)) + { + throw new ArgumentException("Specify subscriptionId in the configuration."); + } + if (string.IsNullOrEmpty(configuration.ResourceGroupName)) + { + throw new ArgumentException("Specify resourceGroupName in the configuration."); + } + if (string.IsNullOrEmpty(configuration.ServiceName)) + { + throw new ArgumentException("Specify serviceName in the configuration."); + } + if (string.IsNullOrEmpty(configuration.WorkspaceName)) + { + throw new ArgumentException("Specify workspaceName in the configuration."); + } + + _logger = logger; + + // load configuration from env vars + configuration.SubscriptionId = ResolveEnvironmentVariable(configuration.SubscriptionId, "subscriptionId"); + configuration.ResourceGroupName = ResolveEnvironmentVariable(configuration.ResourceGroupName, "resourceGroupName"); + configuration.ServiceName = ResolveEnvironmentVariable(configuration.ServiceName, "serviceName"); + configuration.WorkspaceName = ResolveEnvironmentVariable(configuration.WorkspaceName, "workspaceName"); + + _configuration = configuration; + + _authenticationHandler = new(_credential, _scopes) + { + InnerHandler = new TracingDelegatingHandler(logger) + { + InnerHandler = new HttpClientHandler() + } + }; + _httpClient = new HttpClient(_authenticationHandler); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + using var azureLogger = AzureEventSourceListener.CreateConsoleLogger(EventLevel.Verbose); + } + } + + private string ResolveEnvironmentVariable(string value, string settingName) + { + if (!value.StartsWith('@')) + { + return value; + } + + var variableName = value[1..]; + _logger.LogWarning( + "Referencing environment variables using @{VariableName} in {SettingName} is deprecated and will be removed in a future version. Use ${{NAME}} instead", + variableName, settingName); + var variableValue = Environment.GetEnvironmentVariable(variableName); + return string.IsNullOrEmpty(variableValue) + ? throw new ArgumentException($"Environment variable {variableName} referenced by {settingName} isn't set.") + : variableValue; + } + + internal Task GetAccessTokenAsync(CancellationToken cancellationToken) => + _authenticationHandler.GetAccessTokenAsync(cancellationToken); + + internal async Task GetApisAsync() + { + _logger.LogInformation("Loading APIs from API Center..."); + + var res = await _httpClient.GetStringAsync($"https://management.azure.com/subscriptions/{_configuration.SubscriptionId}/resourceGroups/{_configuration.ResourceGroupName}/providers/Microsoft.ApiCenter/services/{_configuration.ServiceName}/workspaces/{_configuration.WorkspaceName}/apis?api-version=2024-03-01"); + var collection = JsonSerializer.Deserialize>(res, ProxyUtils.JsonSerializerOptions); + return collection?.Value; + } + + internal async Task GetDefinitionAsync(string definitionId, CancellationToken cancellationToken) + { + _logger.LogDebug("Loading API definition {Id}...", definitionId); + + var res = await _httpClient.GetStringAsync($"https://management.azure.com{definitionId}?api-version=2024-03-01", cancellationToken); + return JsonSerializer.Deserialize(res, ProxyUtils.JsonSerializerOptions); + } + + internal async Task GetDefinitionsAsync(string versionId, CancellationToken cancellationToken) + { + _logger.LogDebug("Loading API definitions for version {Id}...", versionId); + + var res = await _httpClient.GetStringAsync($"https://management.azure.com{versionId}/definitions?api-version=2024-03-01", cancellationToken); + var collection = JsonSerializer.Deserialize>(res, ProxyUtils.JsonSerializerOptions); + return collection?.Value; + } + + internal async Task GetDeploymentsAsync(string apiId, CancellationToken cancellationToken) + { + _logger.LogDebug("Loading API deployments for {ApiName}...", apiId); + + var res = await _httpClient.GetStringAsync($"https://management.azure.com{apiId}/deployments?api-version=2024-03-01", cancellationToken); + var collection = JsonSerializer.Deserialize>(res, ProxyUtils.JsonSerializerOptions); + return collection?.Value; + } + + internal async Task GetVersionsAsync(string apiId, CancellationToken cancellationToken) + { + _logger.LogDebug("Loading API versions for {ApiName}...", apiId); + + var res = await _httpClient.GetStringAsync($"https://management.azure.com{apiId}/versions?api-version=2024-03-01", cancellationToken); + var collection = JsonSerializer.Deserialize>(res, ProxyUtils.JsonSerializerOptions); + return collection?.Value; + } + + internal async Task PostExportSpecificationAsync(string definitionId, CancellationToken cancellationToken) + { + var definitionRes = await _httpClient.PostAsync($"https://management.azure.com{definitionId}/exportSpecification?api-version=2024-03-01", null, cancellationToken); + return await definitionRes.Content.ReadFromJsonAsync(ProxyUtils.JsonSerializerOptions, cancellationToken); + } + + internal async Task PostImportSpecificationAsync(ApiSpecImport apiSpecImport, string definitionId, CancellationToken cancellationToken) + { + using var content = new StringContent(JsonSerializer.Serialize(apiSpecImport, ProxyUtils.JsonSerializerOptions), Encoding.UTF8, "application/json"); + return await _httpClient.PostAsync($"https://management.azure.com{definitionId}/importSpecification?api-version=2024-03-01", content, cancellationToken); + } + + internal async Task PutApiAsync(Api api, string apiName, CancellationToken cancellationToken) + { + using var content = new StringContent(JsonSerializer.Serialize(api, ProxyUtils.JsonSerializerOptions), Encoding.UTF8, "application/json"); + var res = await _httpClient.PutAsync($"https://management.azure.com/subscriptions/{_configuration.SubscriptionId}/resourceGroups/{_configuration.ResourceGroupName}/providers/Microsoft.ApiCenter/services/{_configuration.ServiceName}/workspaces/{_configuration.WorkspaceName}/apis/{apiName}?api-version=2024-03-01", content, cancellationToken); + + var resContent = await res.Content.ReadAsStringAsync(cancellationToken); + _logger.LogDebug("{Response}", resContent); + + return res.IsSuccessStatusCode ? + JsonSerializer.Deserialize(resContent, ProxyUtils.JsonSerializerOptions) : + null; + } + + internal async Task PutVersionAsync(ApiVersion apiVersion, string apiId, string apiName, CancellationToken cancellationToken) + { + using var content = new StringContent(JsonSerializer.Serialize(apiVersion, ProxyUtils.JsonSerializerOptions), Encoding.UTF8, "application/json"); + var res = await _httpClient.PutAsync($"https://management.azure.com{apiId}/versions/{apiName}?api-version=2024-03-01", content, cancellationToken); + + var resContent = await res.Content.ReadAsStringAsync(cancellationToken); + _logger.LogDebug("{Response}", resContent); + + return res.IsSuccessStatusCode ? + JsonSerializer.Deserialize(resContent, ProxyUtils.JsonSerializerOptions) : + null; + } + + internal async Task PutDefinitionAsync(ApiDefinition apiDefinition, string apiVersionId, string definitionName, CancellationToken cancellationToken) + { + using var content = new StringContent(JsonSerializer.Serialize(apiDefinition, ProxyUtils.JsonSerializerOptions), Encoding.UTF8, "application/json"); + var res = await _httpClient.PutAsync($"https://management.azure.com{apiVersionId}/definitions/{definitionName}?api-version=2024-03-01", content, cancellationToken); + + var resContent = await res.Content.ReadAsStringAsync(cancellationToken); + _logger.LogDebug("{Response}", resContent); + + return res.IsSuccessStatusCode ? + JsonSerializer.Deserialize(resContent, ProxyUtils.JsonSerializerOptions) : + null; + } + + public void Dispose() + { + _httpClient.Dispose(); + _authenticationHandler.Dispose(); + GC.SuppressFinalize(this); + } } \ No newline at end of file diff --git a/DevProxy.Plugins/Manipulation/RewritesLoader.cs b/DevProxy.Plugins/Manipulation/RewritesLoader.cs index 09526f0bb..9b45f7bcc 100644 --- a/DevProxy.Plugins/Manipulation/RewritesLoader.cs +++ b/DevProxy.Plugins/Manipulation/RewritesLoader.cs @@ -1,44 +1,47 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using DevProxy.Abstractions.Plugins; -using DevProxy.Abstractions.Proxy; -using DevProxy.Abstractions.Utils; -using Microsoft.Extensions.Logging; -using System.Text.Json; - -namespace DevProxy.Plugins.Manipulation; - -internal sealed class RewritesLoader( - HttpClient httpClient, - ILogger logger, - RewritePluginConfiguration configuration, - IProxyConfiguration proxyConfiguration) : - BaseLoader(httpClient, logger, proxyConfiguration) -{ - private readonly RewritePluginConfiguration _configuration = configuration; - - protected override string FilePath => _configuration.RewritesFile; - - protected override void LoadData(string fileContents) - { - try - { - var rewritesConfig = JsonSerializer.Deserialize(fileContents, ProxyUtils.JsonSerializerOptions); - var configRewrites = rewritesConfig?.Rewrites; - if (configRewrites is not null) - { - _configuration.Rewrites = configRewrites; - if (Logger.IsEnabled(LogLevel.Information)) - { - Logger.LogInformation("Rewrites for {ConfigResponseCount} url patterns loaded from {RewritesFile}", configRewrites.Count(), _configuration.RewritesFile); - } - } - } - catch (Exception ex) - { - Logger.LogError(ex, "An error has occurred while reading {RewritesFile}:", _configuration.RewritesFile); - } - } -} +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Plugins; +using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Utils; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace DevProxy.Plugins.Manipulation; + +internal sealed class RewritesLoader( + HttpClient httpClient, + ILogger logger, + RewritePluginConfiguration configuration, + IProxyConfiguration proxyConfiguration) : + BaseLoader(httpClient, logger, proxyConfiguration) +{ + private readonly RewritePluginConfiguration _configuration = configuration; + + protected override string FilePath => _configuration.RewritesFile; + // ${name} in out.url is a regex named group reference, so leave placeholders + // as-is. RewritePlugin resolves ${env:NAME} per request. + protected override bool ResolveEnvironmentVariables => false; + + protected override void LoadData(string fileContents) + { + try + { + var rewritesConfig = JsonSerializer.Deserialize(fileContents, ProxyUtils.JsonSerializerOptions); + var configRewrites = rewritesConfig?.Rewrites; + if (configRewrites is not null) + { + _configuration.Rewrites = configRewrites; + if (Logger.IsEnabled(LogLevel.Information)) + { + Logger.LogInformation("Rewrites for {ConfigResponseCount} url patterns loaded from {RewritesFile}", configRewrites.Count(), _configuration.RewritesFile); + } + } + } + catch (Exception ex) + { + Logger.LogError(ex, "An error has occurred while reading {RewritesFile}:", _configuration.RewritesFile); + } + } +} diff --git a/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs b/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs index f83fa5ffa..936682ed5 100644 --- a/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs +++ b/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs @@ -1,329 +1,330 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using DevProxy.Abstractions.Plugins; -using DevProxy.Abstractions.Proxy; -using DevProxy.Abstractions.Utils; -using DevProxy.Plugins.Models; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.OpenApi; -using Microsoft.OpenApi.Reader; - -namespace DevProxy.Plugins.Reporting; - -public sealed class MinimalPermissionsGuidancePluginConfiguration -{ - public string? ApiSpecsFolderPath { get; set; } - public IEnumerable? PermissionsToExclude { get; set; } - public string? SchemeName { get; set; } -} - -public sealed class MinimalPermissionsGuidancePlugin( - HttpClient httpClient, - ILogger logger, - ISet urlsToWatch, - IProxyConfiguration proxyConfiguration, - IConfigurationSection pluginConfigSection) : - BaseReportingPlugin( - httpClient, - logger, - urlsToWatch, - proxyConfiguration, - pluginConfigSection) -{ - private Dictionary? _apiSpecsByUrl; - - public override string Name => nameof(MinimalPermissionsGuidancePlugin); - - public override async Task InitializeAsync(InitArgs e, CancellationToken cancellationToken) - { - await base.InitializeAsync(e, cancellationToken); - - InitializeApiSpecsFolderPath(); - InitializePermissionsToExclude(); - } - - public override async Task AfterRecordingStopAsync(RecordingArgs e, CancellationToken cancellationToken) - { - Logger.LogTrace("{Method} called", nameof(AfterRecordingStopAsync)); - - ArgumentNullException.ThrowIfNull(e); - - var interceptedRequests = e.RequestLogs - .Where(l => - l.MessageType == MessageType.InterceptedRequest && - !l.Message.StartsWith("OPTIONS", StringComparison.OrdinalIgnoreCase) && - l.Context?.Session is not null && - ProxyUtils.MatchesUrlToWatch(UrlsToWatch, l.Context.Session.Request.RequestUri.AbsoluteUri) && - l.Context.Session.Request.Headers.Any(h => h.Name.Equals("authorization", StringComparison.OrdinalIgnoreCase)) - ); - if (!interceptedRequests.Any()) - { - Logger.LogRequest("No requests to process", MessageType.Skipped); - return; - } - - Logger.LogInformation("Checking if recorded API requests use minimal permissions as defined in API specs..."); - - _apiSpecsByUrl ??= await LoadApiSpecsAsync(Configuration.ApiSpecsFolderPath!, cancellationToken); - if (_apiSpecsByUrl is null || _apiSpecsByUrl.Count == 0) - { - Logger.LogWarning("No API definitions found in the specified folder."); - return; - } - - var (requestsByApiSpec, unmatchedApiSpecRequests) = GetRequestsByApiSpec(interceptedRequests, _apiSpecsByUrl); - - var errors = new List(); - var results = new List(); - var unmatchedRequests = new List( - unmatchedApiSpecRequests.Select(r => r.Message) - ); - - foreach (var (apiSpec, requests) in requestsByApiSpec) - { - var minimalPermissions = apiSpec.CheckMinimalPermissions(requests, Logger, Configuration.SchemeName); - - IEnumerable excessivePermissions = [.. minimalPermissions.TokenPermissions - .Except(Configuration.PermissionsToExclude ?? []) - .Except(minimalPermissions.MinimalScopes) - ]; - - var result = new MinimalPermissionsGuidancePluginReportApiResult - { - ApiName = GetApiName(minimalPermissions.OperationsFromRequests.Any() ? - minimalPermissions.OperationsFromRequests.First().OriginalUrl : null), - Requests = [.. minimalPermissions.OperationsFromRequests - .Select(o => $"{o.Method} {o.OriginalUrl}") - .Distinct()], - TokenPermissions = [.. minimalPermissions.TokenPermissions.Distinct()], - MinimalPermissions = minimalPermissions.MinimalScopes, - ExcessivePermissions = excessivePermissions, - UsesMinimalPermissions = !excessivePermissions.Any(), - SchemeName = Configuration.SchemeName - }; - results.Add(result); - - var unmatchedApiRequests = minimalPermissions.OperationsFromRequests - .Where(o => minimalPermissions.UnmatchedOperations.Contains($"{o.Method} {o.TokenizedUrl}")) - .Select(o => $"{o.Method} {o.OriginalUrl}"); - unmatchedRequests.AddRange(unmatchedApiRequests); - errors.AddRange(minimalPermissions.Errors); - - if (result.UsesMinimalPermissions) - { - if (string.IsNullOrWhiteSpace(Configuration.SchemeName)) - { - if (Logger.IsEnabled(LogLevel.Information)) - { - Logger.LogInformation( - "API {ApiName} is called with minimal permissions: {MinimalPermissions}", - result.ApiName, - string.Join(", ", result.MinimalPermissions) - ); - } - } - else - { - if (Logger.IsEnabled(LogLevel.Information)) - { - Logger.LogInformation( - "API {ApiName} is called with minimal permissions of '{SchemeName}' scheme: {MinimalPermissions}", - result.ApiName, - Configuration.SchemeName, - string.Join(", ", result.MinimalPermissions) - ); - } - } - } - else - { - if (string.IsNullOrWhiteSpace(Configuration.SchemeName)) - { - if (Logger.IsEnabled(LogLevel.Warning)) - { - Logger.LogWarning( - "Calling API {ApiName} with excessive permissions: {ExcessivePermissions}. Minimal permissions are: {MinimalPermissions}", - result.ApiName, - string.Join(", ", result.ExcessivePermissions), - string.Join(", ", result.MinimalPermissions) - ); - } - } - else - { - if (Logger.IsEnabled(LogLevel.Warning)) - { - Logger.LogWarning( - "Calling API {ApiName} with excessive permissions of '{SchemeName}' scheme: {ExcessivePermissions}. Minimal permissions are: {MinimalPermissions}", - result.ApiName, - Configuration.SchemeName, - string.Join(", ", result.ExcessivePermissions), - string.Join(", ", result.MinimalPermissions) - ); - } - } - } - - if (unmatchedApiRequests.Any() && - Logger.IsEnabled(LogLevel.Warning)) - { - Logger.LogWarning( - "Unmatched requests for API {ApiName}:{NewLine}- {UnmatchedRequests}", - result.ApiName, - Environment.NewLine, - string.Join($"{Environment.NewLine}- ", unmatchedApiRequests) - ); - } - - if (minimalPermissions.Errors.Any() && - Logger.IsEnabled(LogLevel.Warning)) - { - Logger.LogWarning( - "Errors for API {ApiName}:{NewLine}- {Errors}", - result.ApiName, - Environment.NewLine, - string.Join($"{Environment.NewLine}- ", minimalPermissions.Errors.Select(e => $"{e.Request}: {e.Error}")) - ); - } - } - - var report = new MinimalPermissionsGuidancePluginReport() - { - Results = [.. results], - UnmatchedRequests = [.. unmatchedRequests], - Errors = [.. errors], - ExcludedPermissions = Configuration.PermissionsToExclude - }; - - if (Configuration.PermissionsToExclude?.Any() == true && - Logger.IsEnabled(LogLevel.Information)) - { - Logger.LogInformation("Excluding the following permissions: {Permissions}", string.Join(", ", Configuration.PermissionsToExclude)); - } - - StoreReport(report, e); - - Logger.LogTrace("Left {Name}", nameof(AfterRecordingStopAsync)); - } - - private void InitializeApiSpecsFolderPath() - { - if (string.IsNullOrWhiteSpace(Configuration.ApiSpecsFolderPath)) - { - Enabled = false; - throw new InvalidOperationException("ApiSpecsFolderPath is required."); - } - - Configuration.ApiSpecsFolderPath = ProxyUtils.GetFullPath(Configuration.ApiSpecsFolderPath, ProxyConfiguration.ConfigFile); - if (!Path.Exists(Configuration.ApiSpecsFolderPath)) - { - Enabled = false; - throw new FileNotFoundException($"ApiSpecsFolderPath '{Configuration.ApiSpecsFolderPath}' does not exist."); - } - } - - private void InitializePermissionsToExclude() - { - var key = nameof(MinimalPermissionsGuidancePluginConfiguration.PermissionsToExclude) - .ToCamelCase(); - - string[] defaultPermissionsToExclude = ["profile", "openid", "offline_access", "email"]; - Configuration.PermissionsToExclude = GetConfigurationValue(key, Configuration.PermissionsToExclude, defaultPermissionsToExclude); - } - - private async Task> LoadApiSpecsAsync(string apiSpecsFolderPath, CancellationToken cancellationToken) - { - var apiDefinitions = new Dictionary(); - foreach (var file in Directory.EnumerateFiles(apiSpecsFolderPath, "*.*", SearchOption.AllDirectories)) - { - cancellationToken.ThrowIfCancellationRequested(); - - var extension = Path.GetExtension(file); - if (!extension.Equals(".json", StringComparison.OrdinalIgnoreCase) && - !extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase) && - !extension.Equals(".yml", StringComparison.OrdinalIgnoreCase)) - { - Logger.LogDebug("Skipping file '{File}' because it is not a JSON or YAML file", file); - continue; - } - - Logger.LogDebug("Processing file '{File}'...", file); - try - { - var fileContents = await File.ReadAllTextAsync(file, cancellationToken); - fileContents = ProxyUtils.ReplaceVariables(fileContents, ProxyConfiguration.Env, v => $"{{{v}}}"); - - var settings = new OpenApiReaderSettings(); - settings.AddYamlReader(); - var readResult = OpenApiDocument.Parse(fileContents, null, settings); - var apiDefinition = readResult.Document; - if (apiDefinition is null) - { - continue; - } - if (apiDefinition.Servers is null || apiDefinition.Servers.Count == 0) - { - Logger.LogDebug("No servers found in API definition file '{File}'", file); - continue; - } - foreach (var server in apiDefinition.Servers) - { - if (server.Url is null) - { - Logger.LogDebug("No URL found for server '{Server}'", server.Description ?? "unnamed"); - continue; - } - apiDefinitions[server.Url] = apiDefinition; - } - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to load API definition from file '{File}'", file); - } - } - return apiDefinitions; - } - - private (Dictionary> RequestsByApiSpec, IEnumerable UnmatchedRequests) GetRequestsByApiSpec(IEnumerable interceptedRequests, Dictionary apiSpecsByUrl) - { - var unmatchedRequests = new List(); - var requestsByApiSpec = new Dictionary>(); - foreach (var request in interceptedRequests) - { - var url = request.Message.Split(' ')[1]; - Logger.LogDebug("Matching request {RequestUrl} to API specs...", url); - - var matchingKey = apiSpecsByUrl.Keys.FirstOrDefault(url.StartsWith); - if (matchingKey is null) - { - Logger.LogDebug("No matching API spec found for {RequestUrl}", url); - unmatchedRequests.Add(request); - continue; - } - - if (!requestsByApiSpec.TryGetValue(apiSpecsByUrl[matchingKey], out var value)) - { - value = []; - requestsByApiSpec[apiSpecsByUrl[matchingKey]] = value; - } - - value.Add(request); - } - - return (requestsByApiSpec, unmatchedRequests); - } - - private static string GetApiName(string? url) - { - if (string.IsNullOrWhiteSpace(url)) - { - return "Unknown"; - } - - var uri = new Uri(url); - return uri.Authority; - } -} +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Plugins; +using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Utils; +using DevProxy.Plugins.Models; +using DevProxy.Plugins.Utils; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; + +namespace DevProxy.Plugins.Reporting; + +public sealed class MinimalPermissionsGuidancePluginConfiguration +{ + public string? ApiSpecsFolderPath { get; set; } + public IEnumerable? PermissionsToExclude { get; set; } + public string? SchemeName { get; set; } +} + +public sealed class MinimalPermissionsGuidancePlugin( + HttpClient httpClient, + ILogger logger, + ISet urlsToWatch, + IProxyConfiguration proxyConfiguration, + IConfigurationSection pluginConfigSection) : + BaseReportingPlugin( + httpClient, + logger, + urlsToWatch, + proxyConfiguration, + pluginConfigSection) +{ + private Dictionary? _apiSpecsByUrl; + + public override string Name => nameof(MinimalPermissionsGuidancePlugin); + + public override async Task InitializeAsync(InitArgs e, CancellationToken cancellationToken) + { + await base.InitializeAsync(e, cancellationToken); + + InitializeApiSpecsFolderPath(); + InitializePermissionsToExclude(); + } + + public override async Task AfterRecordingStopAsync(RecordingArgs e, CancellationToken cancellationToken) + { + Logger.LogTrace("{Method} called", nameof(AfterRecordingStopAsync)); + + ArgumentNullException.ThrowIfNull(e); + + var interceptedRequests = e.RequestLogs + .Where(l => + l.MessageType == MessageType.InterceptedRequest && + !l.Message.StartsWith("OPTIONS", StringComparison.OrdinalIgnoreCase) && + l.Context?.Session is not null && + ProxyUtils.MatchesUrlToWatch(UrlsToWatch, l.Context.Session.Request.RequestUri.AbsoluteUri) && + l.Context.Session.Request.Headers.Any(h => h.Name.Equals("authorization", StringComparison.OrdinalIgnoreCase)) + ); + if (!interceptedRequests.Any()) + { + Logger.LogRequest("No requests to process", MessageType.Skipped); + return; + } + + Logger.LogInformation("Checking if recorded API requests use minimal permissions as defined in API specs..."); + + _apiSpecsByUrl ??= await LoadApiSpecsAsync(Configuration.ApiSpecsFolderPath!, cancellationToken); + if (_apiSpecsByUrl is null || _apiSpecsByUrl.Count == 0) + { + Logger.LogWarning("No API definitions found in the specified folder."); + return; + } + + var (requestsByApiSpec, unmatchedApiSpecRequests) = GetRequestsByApiSpec(interceptedRequests, _apiSpecsByUrl); + + var errors = new List(); + var results = new List(); + var unmatchedRequests = new List( + unmatchedApiSpecRequests.Select(r => r.Message) + ); + + foreach (var (apiSpec, requests) in requestsByApiSpec) + { + var minimalPermissions = apiSpec.CheckMinimalPermissions(requests, Logger, Configuration.SchemeName); + + IEnumerable excessivePermissions = [.. minimalPermissions.TokenPermissions + .Except(Configuration.PermissionsToExclude ?? []) + .Except(minimalPermissions.MinimalScopes) + ]; + + var result = new MinimalPermissionsGuidancePluginReportApiResult + { + ApiName = GetApiName(minimalPermissions.OperationsFromRequests.Any() ? + minimalPermissions.OperationsFromRequests.First().OriginalUrl : null), + Requests = [.. minimalPermissions.OperationsFromRequests + .Select(o => $"{o.Method} {o.OriginalUrl}") + .Distinct()], + TokenPermissions = [.. minimalPermissions.TokenPermissions.Distinct()], + MinimalPermissions = minimalPermissions.MinimalScopes, + ExcessivePermissions = excessivePermissions, + UsesMinimalPermissions = !excessivePermissions.Any(), + SchemeName = Configuration.SchemeName + }; + results.Add(result); + + var unmatchedApiRequests = minimalPermissions.OperationsFromRequests + .Where(o => minimalPermissions.UnmatchedOperations.Contains($"{o.Method} {o.TokenizedUrl}")) + .Select(o => $"{o.Method} {o.OriginalUrl}"); + unmatchedRequests.AddRange(unmatchedApiRequests); + errors.AddRange(minimalPermissions.Errors); + + if (result.UsesMinimalPermissions) + { + if (string.IsNullOrWhiteSpace(Configuration.SchemeName)) + { + if (Logger.IsEnabled(LogLevel.Information)) + { + Logger.LogInformation( + "API {ApiName} is called with minimal permissions: {MinimalPermissions}", + result.ApiName, + string.Join(", ", result.MinimalPermissions) + ); + } + } + else + { + if (Logger.IsEnabled(LogLevel.Information)) + { + Logger.LogInformation( + "API {ApiName} is called with minimal permissions of '{SchemeName}' scheme: {MinimalPermissions}", + result.ApiName, + Configuration.SchemeName, + string.Join(", ", result.MinimalPermissions) + ); + } + } + } + else + { + if (string.IsNullOrWhiteSpace(Configuration.SchemeName)) + { + if (Logger.IsEnabled(LogLevel.Warning)) + { + Logger.LogWarning( + "Calling API {ApiName} with excessive permissions: {ExcessivePermissions}. Minimal permissions are: {MinimalPermissions}", + result.ApiName, + string.Join(", ", result.ExcessivePermissions), + string.Join(", ", result.MinimalPermissions) + ); + } + } + else + { + if (Logger.IsEnabled(LogLevel.Warning)) + { + Logger.LogWarning( + "Calling API {ApiName} with excessive permissions of '{SchemeName}' scheme: {ExcessivePermissions}. Minimal permissions are: {MinimalPermissions}", + result.ApiName, + Configuration.SchemeName, + string.Join(", ", result.ExcessivePermissions), + string.Join(", ", result.MinimalPermissions) + ); + } + } + } + + if (unmatchedApiRequests.Any() && + Logger.IsEnabled(LogLevel.Warning)) + { + Logger.LogWarning( + "Unmatched requests for API {ApiName}:{NewLine}- {UnmatchedRequests}", + result.ApiName, + Environment.NewLine, + string.Join($"{Environment.NewLine}- ", unmatchedApiRequests) + ); + } + + if (minimalPermissions.Errors.Any() && + Logger.IsEnabled(LogLevel.Warning)) + { + Logger.LogWarning( + "Errors for API {ApiName}:{NewLine}- {Errors}", + result.ApiName, + Environment.NewLine, + string.Join($"{Environment.NewLine}- ", minimalPermissions.Errors.Select(e => $"{e.Request}: {e.Error}")) + ); + } + } + + var report = new MinimalPermissionsGuidancePluginReport() + { + Results = [.. results], + UnmatchedRequests = [.. unmatchedRequests], + Errors = [.. errors], + ExcludedPermissions = Configuration.PermissionsToExclude + }; + + if (Configuration.PermissionsToExclude?.Any() == true && + Logger.IsEnabled(LogLevel.Information)) + { + Logger.LogInformation("Excluding the following permissions: {Permissions}", string.Join(", ", Configuration.PermissionsToExclude)); + } + + StoreReport(report, e); + + Logger.LogTrace("Left {Name}", nameof(AfterRecordingStopAsync)); + } + + private void InitializeApiSpecsFolderPath() + { + if (string.IsNullOrWhiteSpace(Configuration.ApiSpecsFolderPath)) + { + Enabled = false; + throw new InvalidOperationException("ApiSpecsFolderPath is required."); + } + + Configuration.ApiSpecsFolderPath = ProxyUtils.GetFullPath(Configuration.ApiSpecsFolderPath, ProxyConfiguration.ConfigFile); + if (!Path.Exists(Configuration.ApiSpecsFolderPath)) + { + Enabled = false; + throw new FileNotFoundException($"ApiSpecsFolderPath '{Configuration.ApiSpecsFolderPath}' does not exist."); + } + } + + private void InitializePermissionsToExclude() + { + var key = nameof(MinimalPermissionsGuidancePluginConfiguration.PermissionsToExclude) + .ToCamelCase(); + + string[] defaultPermissionsToExclude = ["profile", "openid", "offline_access", "email"]; + Configuration.PermissionsToExclude = GetConfigurationValue(key, Configuration.PermissionsToExclude, defaultPermissionsToExclude); + } + + private async Task> LoadApiSpecsAsync(string apiSpecsFolderPath, CancellationToken cancellationToken) + { + var apiDefinitions = new Dictionary(); + foreach (var file in Directory.EnumerateFiles(apiSpecsFolderPath, "*.*", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var extension = Path.GetExtension(file); + if (!extension.Equals(".json", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".yml", StringComparison.OrdinalIgnoreCase)) + { + Logger.LogDebug("Skipping file '{File}' because it is not a JSON or YAML file", file); + continue; + } + + Logger.LogDebug("Processing file '{File}'...", file); + try + { + var fileContents = await File.ReadAllTextAsync(file, cancellationToken); + fileContents = MinimalPermissionsUtils.ResolveApiSpecVariables(fileContents, file, ProxyConfiguration.Env, Logger); + + var settings = new OpenApiReaderSettings(); + settings.AddYamlReader(); + var readResult = OpenApiDocument.Parse(fileContents, null, settings); + var apiDefinition = readResult.Document; + if (apiDefinition is null) + { + continue; + } + if (apiDefinition.Servers is null || apiDefinition.Servers.Count == 0) + { + Logger.LogDebug("No servers found in API definition file '{File}'", file); + continue; + } + foreach (var server in apiDefinition.Servers) + { + if (server.Url is null) + { + Logger.LogDebug("No URL found for server '{Server}'", server.Description ?? "unnamed"); + continue; + } + apiDefinitions[server.Url] = apiDefinition; + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to load API definition from file '{File}'", file); + } + } + return apiDefinitions; + } + + private (Dictionary> RequestsByApiSpec, IEnumerable UnmatchedRequests) GetRequestsByApiSpec(IEnumerable interceptedRequests, Dictionary apiSpecsByUrl) + { + var unmatchedRequests = new List(); + var requestsByApiSpec = new Dictionary>(); + foreach (var request in interceptedRequests) + { + var url = request.Message.Split(' ')[1]; + Logger.LogDebug("Matching request {RequestUrl} to API specs...", url); + + var matchingKey = apiSpecsByUrl.Keys.FirstOrDefault(url.StartsWith); + if (matchingKey is null) + { + Logger.LogDebug("No matching API spec found for {RequestUrl}", url); + unmatchedRequests.Add(request); + continue; + } + + if (!requestsByApiSpec.TryGetValue(apiSpecsByUrl[matchingKey], out var value)) + { + value = []; + requestsByApiSpec[apiSpecsByUrl[matchingKey]] = value; + } + + value.Add(request); + } + + return (requestsByApiSpec, unmatchedRequests); + } + + private static string GetApiName(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return "Unknown"; + } + + var uri = new Uri(url); + return uri.Authority; + } +} diff --git a/DevProxy.Plugins/Reporting/MinimalPermissionsPlugin.cs b/DevProxy.Plugins/Reporting/MinimalPermissionsPlugin.cs index 943b4b2ec..b2e925456 100644 --- a/DevProxy.Plugins/Reporting/MinimalPermissionsPlugin.cs +++ b/DevProxy.Plugins/Reporting/MinimalPermissionsPlugin.cs @@ -1,276 +1,277 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using DevProxy.Abstractions.Plugins; -using DevProxy.Abstractions.Proxy; -using DevProxy.Abstractions.Utils; -using DevProxy.Plugins.Models; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.OpenApi; -using Microsoft.OpenApi.Reader; -using System.Text.RegularExpressions; - -namespace DevProxy.Plugins.Reporting; - -public sealed class MinimalPermissionsPluginConfiguration -{ - public string? ApiSpecsFolderPath { get; set; } - public string? SchemeName { get; set; } -} - -public sealed class MinimalPermissionsPlugin( - HttpClient httpClient, - ILogger logger, - ISet urlsToWatch, - IProxyConfiguration proxyConfiguration, - IConfigurationSection pluginConfigurationSection) : - BaseReportingPlugin( - httpClient, - logger, - urlsToWatch, - proxyConfiguration, - pluginConfigurationSection) -{ - private Dictionary? _apiSpecsByUrl; - - public override string Name => nameof(MinimalPermissionsPlugin); - - public override async Task InitializeAsync(InitArgs e, CancellationToken cancellationToken) - { - await base.InitializeAsync(e, cancellationToken); - - if (string.IsNullOrWhiteSpace(Configuration.ApiSpecsFolderPath)) - { - Enabled = false; - throw new InvalidOperationException("ApiSpecsFolderPath is required."); - } - - Configuration.ApiSpecsFolderPath = ProxyUtils.GetFullPath(Configuration.ApiSpecsFolderPath, ProxyConfiguration.ConfigFile); - if (!Path.Exists(Configuration.ApiSpecsFolderPath)) - { - Enabled = false; - throw new InvalidOperationException($"ApiSpecsFolderPath '{Configuration.ApiSpecsFolderPath}' does not exist."); - } - } - - public override async Task AfterRecordingStopAsync(RecordingArgs e, CancellationToken cancellationToken) - { - Logger.LogTrace("{Method} called", nameof(AfterRecordingStopAsync)); - - ArgumentNullException.ThrowIfNull(e); - - var interceptedRequests = e.RequestLogs - .Where(l => - l.MessageType == MessageType.InterceptedRequest && - !l.Message.StartsWith("OPTIONS", StringComparison.OrdinalIgnoreCase) && - l.Context?.Session is not null && - ProxyUtils.MatchesUrlToWatch(UrlsToWatch, l.Context.Session.Request.RequestUri.AbsoluteUri) - ); - if (!interceptedRequests.Any()) - { - Logger.LogRequest("No requests to process", MessageType.Skipped); - return; - } - - Logger.LogInformation("Checking if recorded API requests use minimal permissions as defined in API specs..."); - - _apiSpecsByUrl ??= await LoadApiSpecsAsync(Configuration.ApiSpecsFolderPath!, cancellationToken); - if (_apiSpecsByUrl is null || _apiSpecsByUrl.Count == 0) - { - Logger.LogWarning("No API definitions found in the specified folder."); - return; - } - - var (requestsByApiSpec, unmatchedApiSpecRequests) = GetRequestsByApiSpec(interceptedRequests, _apiSpecsByUrl); - - var errors = new List(); - var results = new List(); - var unmatchedRequests = new List( - unmatchedApiSpecRequests.Select(r => r.Message) - ); - - foreach (var (apiSpec, requests) in requestsByApiSpec) - { - var minimalPermissions = apiSpec.CheckMinimalPermissions(requests, Logger, Configuration.SchemeName); - - var result = new MinimalPermissionsPluginReportApiResult - { - ApiName = GetApiName(minimalPermissions.OperationsFromRequests.Any() ? - minimalPermissions.OperationsFromRequests.First().OriginalUrl : null), - Requests = [.. minimalPermissions.OperationsFromRequests - .Select(o => $"{o.Method} {o.OriginalUrl}") - .Distinct()], - TokenPermissions = [.. minimalPermissions.TokenPermissions.Distinct()], - MinimalPermissions = minimalPermissions.MinimalScopes, - SchemeName = Configuration.SchemeName - }; - results.Add(result); - - var unmatchedApiRequests = minimalPermissions.OperationsFromRequests - .Where(o => minimalPermissions.UnmatchedOperations.Contains($"{o.Method} {o.TokenizedUrl}")) - .Select(o => $"{o.Method} {o.OriginalUrl}"); - unmatchedRequests.AddRange(unmatchedApiRequests); - errors.AddRange(minimalPermissions.Errors); - - if (unmatchedApiRequests.Any()) - { - if (Logger.IsEnabled(LogLevel.Warning)) - { - Logger.LogWarning( - "Unmatched requests for API {ApiName}:{NewLine}- {UnmatchedRequests}", - result.ApiName, - Environment.NewLine, - string.Join($"{Environment.NewLine}- ", unmatchedApiRequests) - ); - } - } - - if (minimalPermissions.Errors.Any()) - { - if (Logger.IsEnabled(LogLevel.Warning)) - { - Logger.LogWarning( - "Errors for API {ApiName}:{NewLine}- {Errors}", - result.ApiName, - Environment.NewLine, - string.Join($"{Environment.NewLine}- ", minimalPermissions.Errors.Select(e => $"{e.Request}: {e.Error}")) - ); - } - } - - if (string.IsNullOrWhiteSpace(Configuration.SchemeName)) - { - if (Logger.IsEnabled(LogLevel.Information)) - { - Logger.LogInformation("Minimal permissions: {MinimalScopes}", string.Join(", ", result.MinimalPermissions)); - } - } - else - { - if (Logger.IsEnabled(LogLevel.Information)) - { - Logger.LogInformation("Minimal permissions of '{SchemeName}' scheme: {MinimalScopes}", - Configuration.SchemeName, string.Join(", ", result.MinimalPermissions)); - } - } - } - - var report = new MinimalPermissionsPluginReport() - { - Results = [.. results], - UnmatchedRequests = [.. unmatchedRequests], - Errors = [.. errors] - }; - - StoreReport(report, e); - - Logger.LogTrace("Left {Name}", nameof(AfterRecordingStopAsync)); - } - - private async Task> LoadApiSpecsAsync(string apiSpecsFolderPath, CancellationToken cancellationToken) - { - var apiDefinitions = new Dictionary(); - foreach (var file in Directory.EnumerateFiles(apiSpecsFolderPath, "*.*", SearchOption.AllDirectories)) - { - cancellationToken.ThrowIfCancellationRequested(); - - var extension = Path.GetExtension(file); - if (!extension.Equals(".json", StringComparison.OrdinalIgnoreCase) && - !extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase) && - !extension.Equals(".yml", StringComparison.OrdinalIgnoreCase)) - { - Logger.LogDebug("Skipping file '{File}' because it is not a JSON or YAML file", file); - continue; - } - - Logger.LogDebug("Processing file '{File}'...", file); - try - { - var fileContents = await File.ReadAllTextAsync(file, cancellationToken); - fileContents = ProxyUtils.ReplaceVariables(fileContents, ProxyConfiguration.Env, v => $"{{{v}}}"); - - var settings = new OpenApiReaderSettings(); - settings.AddYamlReader(); - var readResult = OpenApiDocument.Parse(fileContents, null, settings); - var apiDefinition = readResult.Document; - if (apiDefinition is null) - { - continue; - } - if (apiDefinition.Servers is null || apiDefinition.Servers.Count == 0) - { - Logger.LogDebug("No servers found in API definition file '{File}'", file); - continue; - } - foreach (var server in apiDefinition.Servers) - { - if (server.Url is null) - { - Logger.LogDebug("No URL found for server '{Server}'", server.Description ?? "unnamed"); - continue; - } - - Logger.LogDebug("Found server '{Server}' with URL '{Url}'", server.Description ?? "unnamed", server.Url); - - var serverUrl = server.Url; - if (server.Url.Contains('{', StringComparison.Ordinal)) - { - serverUrl = ProxyUtils.UrlWithParametersToRegex(server.Url); - Logger.LogDebug("Transformed server URL '{OriginalUrl}' to '{TransformedUrl}'", server.Url, serverUrl); - } - - apiDefinitions[serverUrl] = apiDefinition; - } - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to load API definition from file '{File}'", file); - } - } - return apiDefinitions; - } - - private (Dictionary> RequestsByApiSpec, IEnumerable UnmatchedRequests) GetRequestsByApiSpec(IEnumerable interceptedRequests, Dictionary apiSpecsByUrl) - { - var unmatchedRequests = new List(); - var requestsByApiSpec = new Dictionary>(); - foreach (var request in interceptedRequests) - { - var url = request.Message.Split(' ')[1]; - Logger.LogDebug("Matching request {RequestUrl} to API specs...", url); - - var matchingKey = apiSpecsByUrl.Keys.FirstOrDefault(urlOrPattern => - url.StartsWith(urlOrPattern, StringComparison.OrdinalIgnoreCase) || - Regex.IsMatch(url, urlOrPattern)); - if (matchingKey is null) - { - Logger.LogDebug("No matching API spec found for {RequestUrl}", url); - unmatchedRequests.Add(request); - continue; - } - - if (!requestsByApiSpec.TryGetValue(apiSpecsByUrl[matchingKey], out var value)) - { - value = []; - requestsByApiSpec[apiSpecsByUrl[matchingKey]] = value; - } - - value.Add(request); - } - - return (requestsByApiSpec, unmatchedRequests); - } - - private static string GetApiName(string? url) - { - if (string.IsNullOrWhiteSpace(url)) - { - return "Unknown"; - } - - var uri = new Uri(url); - return uri.Authority; - } -} +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Plugins; +using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Utils; +using DevProxy.Plugins.Models; +using DevProxy.Plugins.Utils; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; +using System.Text.RegularExpressions; + +namespace DevProxy.Plugins.Reporting; + +public sealed class MinimalPermissionsPluginConfiguration +{ + public string? ApiSpecsFolderPath { get; set; } + public string? SchemeName { get; set; } +} + +public sealed class MinimalPermissionsPlugin( + HttpClient httpClient, + ILogger logger, + ISet urlsToWatch, + IProxyConfiguration proxyConfiguration, + IConfigurationSection pluginConfigurationSection) : + BaseReportingPlugin( + httpClient, + logger, + urlsToWatch, + proxyConfiguration, + pluginConfigurationSection) +{ + private Dictionary? _apiSpecsByUrl; + + public override string Name => nameof(MinimalPermissionsPlugin); + + public override async Task InitializeAsync(InitArgs e, CancellationToken cancellationToken) + { + await base.InitializeAsync(e, cancellationToken); + + if (string.IsNullOrWhiteSpace(Configuration.ApiSpecsFolderPath)) + { + Enabled = false; + throw new InvalidOperationException("ApiSpecsFolderPath is required."); + } + + Configuration.ApiSpecsFolderPath = ProxyUtils.GetFullPath(Configuration.ApiSpecsFolderPath, ProxyConfiguration.ConfigFile); + if (!Path.Exists(Configuration.ApiSpecsFolderPath)) + { + Enabled = false; + throw new InvalidOperationException($"ApiSpecsFolderPath '{Configuration.ApiSpecsFolderPath}' does not exist."); + } + } + + public override async Task AfterRecordingStopAsync(RecordingArgs e, CancellationToken cancellationToken) + { + Logger.LogTrace("{Method} called", nameof(AfterRecordingStopAsync)); + + ArgumentNullException.ThrowIfNull(e); + + var interceptedRequests = e.RequestLogs + .Where(l => + l.MessageType == MessageType.InterceptedRequest && + !l.Message.StartsWith("OPTIONS", StringComparison.OrdinalIgnoreCase) && + l.Context?.Session is not null && + ProxyUtils.MatchesUrlToWatch(UrlsToWatch, l.Context.Session.Request.RequestUri.AbsoluteUri) + ); + if (!interceptedRequests.Any()) + { + Logger.LogRequest("No requests to process", MessageType.Skipped); + return; + } + + Logger.LogInformation("Checking if recorded API requests use minimal permissions as defined in API specs..."); + + _apiSpecsByUrl ??= await LoadApiSpecsAsync(Configuration.ApiSpecsFolderPath!, cancellationToken); + if (_apiSpecsByUrl is null || _apiSpecsByUrl.Count == 0) + { + Logger.LogWarning("No API definitions found in the specified folder."); + return; + } + + var (requestsByApiSpec, unmatchedApiSpecRequests) = GetRequestsByApiSpec(interceptedRequests, _apiSpecsByUrl); + + var errors = new List(); + var results = new List(); + var unmatchedRequests = new List( + unmatchedApiSpecRequests.Select(r => r.Message) + ); + + foreach (var (apiSpec, requests) in requestsByApiSpec) + { + var minimalPermissions = apiSpec.CheckMinimalPermissions(requests, Logger, Configuration.SchemeName); + + var result = new MinimalPermissionsPluginReportApiResult + { + ApiName = GetApiName(minimalPermissions.OperationsFromRequests.Any() ? + minimalPermissions.OperationsFromRequests.First().OriginalUrl : null), + Requests = [.. minimalPermissions.OperationsFromRequests + .Select(o => $"{o.Method} {o.OriginalUrl}") + .Distinct()], + TokenPermissions = [.. minimalPermissions.TokenPermissions.Distinct()], + MinimalPermissions = minimalPermissions.MinimalScopes, + SchemeName = Configuration.SchemeName + }; + results.Add(result); + + var unmatchedApiRequests = minimalPermissions.OperationsFromRequests + .Where(o => minimalPermissions.UnmatchedOperations.Contains($"{o.Method} {o.TokenizedUrl}")) + .Select(o => $"{o.Method} {o.OriginalUrl}"); + unmatchedRequests.AddRange(unmatchedApiRequests); + errors.AddRange(minimalPermissions.Errors); + + if (unmatchedApiRequests.Any()) + { + if (Logger.IsEnabled(LogLevel.Warning)) + { + Logger.LogWarning( + "Unmatched requests for API {ApiName}:{NewLine}- {UnmatchedRequests}", + result.ApiName, + Environment.NewLine, + string.Join($"{Environment.NewLine}- ", unmatchedApiRequests) + ); + } + } + + if (minimalPermissions.Errors.Any()) + { + if (Logger.IsEnabled(LogLevel.Warning)) + { + Logger.LogWarning( + "Errors for API {ApiName}:{NewLine}- {Errors}", + result.ApiName, + Environment.NewLine, + string.Join($"{Environment.NewLine}- ", minimalPermissions.Errors.Select(e => $"{e.Request}: {e.Error}")) + ); + } + } + + if (string.IsNullOrWhiteSpace(Configuration.SchemeName)) + { + if (Logger.IsEnabled(LogLevel.Information)) + { + Logger.LogInformation("Minimal permissions: {MinimalScopes}", string.Join(", ", result.MinimalPermissions)); + } + } + else + { + if (Logger.IsEnabled(LogLevel.Information)) + { + Logger.LogInformation("Minimal permissions of '{SchemeName}' scheme: {MinimalScopes}", + Configuration.SchemeName, string.Join(", ", result.MinimalPermissions)); + } + } + } + + var report = new MinimalPermissionsPluginReport() + { + Results = [.. results], + UnmatchedRequests = [.. unmatchedRequests], + Errors = [.. errors] + }; + + StoreReport(report, e); + + Logger.LogTrace("Left {Name}", nameof(AfterRecordingStopAsync)); + } + + private async Task> LoadApiSpecsAsync(string apiSpecsFolderPath, CancellationToken cancellationToken) + { + var apiDefinitions = new Dictionary(); + foreach (var file in Directory.EnumerateFiles(apiSpecsFolderPath, "*.*", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var extension = Path.GetExtension(file); + if (!extension.Equals(".json", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".yml", StringComparison.OrdinalIgnoreCase)) + { + Logger.LogDebug("Skipping file '{File}' because it is not a JSON or YAML file", file); + continue; + } + + Logger.LogDebug("Processing file '{File}'...", file); + try + { + var fileContents = await File.ReadAllTextAsync(file, cancellationToken); + fileContents = MinimalPermissionsUtils.ResolveApiSpecVariables(fileContents, file, ProxyConfiguration.Env, Logger); + + var settings = new OpenApiReaderSettings(); + settings.AddYamlReader(); + var readResult = OpenApiDocument.Parse(fileContents, null, settings); + var apiDefinition = readResult.Document; + if (apiDefinition is null) + { + continue; + } + if (apiDefinition.Servers is null || apiDefinition.Servers.Count == 0) + { + Logger.LogDebug("No servers found in API definition file '{File}'", file); + continue; + } + foreach (var server in apiDefinition.Servers) + { + if (server.Url is null) + { + Logger.LogDebug("No URL found for server '{Server}'", server.Description ?? "unnamed"); + continue; + } + + Logger.LogDebug("Found server '{Server}' with URL '{Url}'", server.Description ?? "unnamed", server.Url); + + var serverUrl = server.Url; + if (server.Url.Contains('{', StringComparison.Ordinal)) + { + serverUrl = ProxyUtils.UrlWithParametersToRegex(server.Url); + Logger.LogDebug("Transformed server URL '{OriginalUrl}' to '{TransformedUrl}'", server.Url, serverUrl); + } + + apiDefinitions[serverUrl] = apiDefinition; + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to load API definition from file '{File}'", file); + } + } + return apiDefinitions; + } + + private (Dictionary> RequestsByApiSpec, IEnumerable UnmatchedRequests) GetRequestsByApiSpec(IEnumerable interceptedRequests, Dictionary apiSpecsByUrl) + { + var unmatchedRequests = new List(); + var requestsByApiSpec = new Dictionary>(); + foreach (var request in interceptedRequests) + { + var url = request.Message.Split(' ')[1]; + Logger.LogDebug("Matching request {RequestUrl} to API specs...", url); + + var matchingKey = apiSpecsByUrl.Keys.FirstOrDefault(urlOrPattern => + url.StartsWith(urlOrPattern, StringComparison.OrdinalIgnoreCase) || + Regex.IsMatch(url, urlOrPattern)); + if (matchingKey is null) + { + Logger.LogDebug("No matching API spec found for {RequestUrl}", url); + unmatchedRequests.Add(request); + continue; + } + + if (!requestsByApiSpec.TryGetValue(apiSpecsByUrl[matchingKey], out var value)) + { + value = []; + requestsByApiSpec[apiSpecsByUrl[matchingKey]] = value; + } + + value.Add(request); + } + + return (requestsByApiSpec, unmatchedRequests); + } + + private static string GetApiName(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return "Unknown"; + } + + var uri = new Uri(url); + return uri.Authority; + } +} diff --git a/DevProxy.Plugins/Utils/MinimalPermissionsUtils.cs b/DevProxy.Plugins/Utils/MinimalPermissionsUtils.cs index e8e690678..898d8ec3c 100644 --- a/DevProxy.Plugins/Utils/MinimalPermissionsUtils.cs +++ b/DevProxy.Plugins/Utils/MinimalPermissionsUtils.cs @@ -1,80 +1,115 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using Microsoft.Extensions.Logging; -using System.IdentityModel.Tokens.Jwt; - -namespace DevProxy.Plugins.Utils; - -static class MinimalPermissionsUtils -{ - /// - /// Gets the scopes from the JWT token. - /// - /// The JWT token including the 'Bearer' prefix. - /// The scopes from the JWT token or empty array if no scopes found or error occurred. - public static string[] GetScopesFromToken(string? jwtToken, ILogger logger) - { - logger.LogDebug("Getting scopes from JWT token..."); - - if (string.IsNullOrEmpty(jwtToken)) - { - return []; - } - - try - { - var token = jwtToken.Split(' ')[1]; - var handler = new JwtSecurityTokenHandler(); - var jsonToken = handler.ReadToken(token) as JwtSecurityToken; - var scopes = jsonToken?.Claims - .Where(c => c.Type == "scp") - .Select(c => c.Value) - .ToArray() ?? []; - - if (logger.IsEnabled(LogLevel.Debug)) - { - logger.LogDebug("Scopes found in the token: {Scopes}", string.Join(", ", scopes)); - } - return scopes; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to parse JWT token"); - return []; - } - } - - public static (IEnumerable minimalScopes, IEnumerable unmatchedOperations) GetMinimalScopes(IEnumerable requests, Dictionary operationsAndScopes) - { - var unmatchedOperations = requests - .Where(o => !operationsAndScopes.Keys.Contains(o, StringComparer.OrdinalIgnoreCase)); - - var minimalScopesPerOperation = operationsAndScopes - .Where(o => requests.Contains(o.Key, StringComparer.OrdinalIgnoreCase)) - .Select(o => new KeyValuePair(o.Key, o.Value.First())) - .ToDictionary(); - - // for each minimal scope check if it overrules any other minimal scope - // (position > 0, because the minimal scope is always first). if it does, - // replace the minimal scope with the overruling scope - foreach (var scope in minimalScopesPerOperation.Values) - { - foreach (var minimalScope in minimalScopesPerOperation) - { - if (Array.IndexOf(operationsAndScopes[minimalScope.Key], scope) > 0) - { - minimalScopesPerOperation[minimalScope.Key] = scope; - } - } - } - - return ( - minimalScopesPerOperation - .Select(s => s.Value) - .Distinct(), - unmatchedOperations - ); - } +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Utils; +using Microsoft.Extensions.Logging; +using System.IdentityModel.Tokens.Jwt; +using System.Text.Json; + +namespace DevProxy.Plugins.Utils; + +static class MinimalPermissionsUtils +{ + /// + /// Resolves environment variable placeholders (${NAME}) in an API + /// specification and the deprecated {NAME} variables passed with --env. + /// + public static string ResolveApiSpecVariables(string fileContents, string filePath, Dictionary legacyVariables, ILogger logger) + { + var unresolvedVariables = new List(); + string resolved; + try + { + // resolve JSON at the value level so that values with quotes don't break the document + resolved = Path.GetExtension(filePath).Equals(".json", StringComparison.OrdinalIgnoreCase) + ? EnvironmentVariablePlaceholders.ResolveInJson(fileContents, unresolvedVariables) + : EnvironmentVariablePlaceholders.Resolve(fileContents, unresolvedVariables); + } + catch (JsonException) + { + unresolvedVariables.Clear(); + resolved = EnvironmentVariablePlaceholders.Resolve(fileContents, unresolvedVariables); + } + EnvironmentVariablePlaceholders.LogUnresolvedVariables(logger, unresolvedVariables, filePath); + + var withLegacyVariables = ProxyUtils.ReplaceVariables(resolved, legacyVariables, v => $"{{{v}}}"); + if (!string.Equals(resolved, withLegacyVariables, StringComparison.Ordinal)) + { + logger.LogWarning( + "Referencing variables using {{NAME}} in {File} is deprecated and will be removed in a future version. Use ${{NAME}} instead", + filePath); + } + + return withLegacyVariables; + } + + /// + /// Gets the scopes from the JWT token. + /// + /// The JWT token including the 'Bearer' prefix. + /// The scopes from the JWT token or empty array if no scopes found or error occurred. + public static string[] GetScopesFromToken(string? jwtToken, ILogger logger) + { + logger.LogDebug("Getting scopes from JWT token..."); + + if (string.IsNullOrEmpty(jwtToken)) + { + return []; + } + + try + { + var token = jwtToken.Split(' ')[1]; + var handler = new JwtSecurityTokenHandler(); + var jsonToken = handler.ReadToken(token) as JwtSecurityToken; + var scopes = jsonToken?.Claims + .Where(c => c.Type == "scp") + .Select(c => c.Value) + .ToArray() ?? []; + + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("Scopes found in the token: {Scopes}", string.Join(", ", scopes)); + } + return scopes; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to parse JWT token"); + return []; + } + } + + public static (IEnumerable minimalScopes, IEnumerable unmatchedOperations) GetMinimalScopes(IEnumerable requests, Dictionary operationsAndScopes) + { + var unmatchedOperations = requests + .Where(o => !operationsAndScopes.Keys.Contains(o, StringComparer.OrdinalIgnoreCase)); + + var minimalScopesPerOperation = operationsAndScopes + .Where(o => requests.Contains(o.Key, StringComparer.OrdinalIgnoreCase)) + .Select(o => new KeyValuePair(o.Key, o.Value.First())) + .ToDictionary(); + + // for each minimal scope check if it overrules any other minimal scope + // (position > 0, because the minimal scope is always first). if it does, + // replace the minimal scope with the overruling scope + foreach (var scope in minimalScopesPerOperation.Values) + { + foreach (var minimalScope in minimalScopesPerOperation) + { + if (Array.IndexOf(operationsAndScopes[minimalScope.Key], scope) > 0) + { + minimalScopesPerOperation[minimalScope.Key] = scope; + } + } + } + + return ( + minimalScopesPerOperation + .Select(s => s.Value) + .Distinct(), + unmatchedOperations + ); + } } \ No newline at end of file diff --git a/DevProxy.Tests/BaseLoaderPlaceholderTests.cs b/DevProxy.Tests/BaseLoaderPlaceholderTests.cs new file mode 100644 index 000000000..f83e47026 --- /dev/null +++ b/DevProxy.Tests/BaseLoaderPlaceholderTests.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Plugins; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System.Text.Json; +using Xunit; + +namespace DevProxy.Tests; + +public sealed class BaseLoaderPlaceholderTests : IDisposable +{ + private const string Variable = "DEVPROXY_TEST_LOADER_PLACEHOLDER"; + private readonly string _folder = Directory.CreateTempSubdirectory("devproxy-loader").FullName; + + public BaseLoaderPlaceholderTests() + { + Environment.SetEnvironmentVariable(Variable, "resolved \"value\""); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(Variable, null); + Directory.Delete(_folder, true); + } + + [Theory] + [InlineData("mocks.json", $$"""{ "body": "${{{Variable}}}", "missing": "${DEVPROXY_TEST_LOADER_MISSING}" }""")] + [InlineData("mocks.yaml", $$""" + body: ${{{Variable}}} + missing: ${DEVPROXY_TEST_LOADER_MISSING} + """)] + public async Task LoadsFileWithResolvedPlaceholders(string fileName, string contents) + { + var loader = await LoadAsync(fileName, contents, resolve: true); + + using var doc = JsonDocument.Parse(loader.Contents!); + Assert.Equal("resolved \"value\"", doc.RootElement.GetProperty("body").GetString()); + Assert.Equal("${DEVPROXY_TEST_LOADER_MISSING}", doc.RootElement.GetProperty("missing").GetString()); + } + + [Fact] + public async Task LeavesPlaceholdersWhenResolutionIsDisabled() + { + var loader = await LoadAsync("rewrites.json", $$"""{ "url": "${{{Variable}}}" }""", resolve: false); + + using var doc = JsonDocument.Parse(loader.Contents!); + Assert.Equal($"${{{Variable}}}", doc.RootElement.GetProperty("url").GetString()); + } + + private async Task LoadAsync(string fileName, string contents, bool resolve) + { + var path = Path.Combine(_folder, fileName); + await File.WriteAllTextAsync(path, contents); + using var httpClient = new HttpClient(); + var loader = new TestLoader(httpClient, NullLogger.Instance, path, resolve); + await loader.InitFileWatcherAsync(CancellationToken.None); + loader.Dispose(); + return loader; + } + + private sealed class TestLoader(HttpClient httpClient, ILogger logger, string filePath, bool resolve) : + BaseLoader(httpClient, logger, new FakeProxyConfiguration()) + { + public string? Contents { get; private set; } + protected override string FilePath => filePath; + protected override bool ResolveEnvironmentVariables => resolve; + protected override void LoadData(string fileContents) => Contents = fileContents; + } +} diff --git a/DevProxy/Extensions/ConfigurationManagerExtensions.cs b/DevProxy/Extensions/ConfigurationManagerExtensions.cs index d37e514c8..65bf9e8e2 100644 --- a/DevProxy/Extensions/ConfigurationManagerExtensions.cs +++ b/DevProxy/Extensions/ConfigurationManagerExtensions.cs @@ -1,30 +1,30 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using DevProxy.Abstractions.Utils; -using DevProxy.Commands; - -#pragma warning disable IDE0130 -namespace Microsoft.Extensions.Configuration; -#pragma warning restore IDE0130 - -static class ConfigurationManagerExtensions -{ - public static ConfigurationManager ConfigureDevProxyConfig(this ConfigurationManager configuration, DevProxyConfigOptions options) - { - configuration.Sources.Clear(); - _ = configuration.SetBasePath(Directory.GetCurrentDirectory()); - - foreach (var configFile in ProxyUtils.GetConfigFileCandidates(options.ConfigFile)) - { - if (!string.IsNullOrEmpty(configFile) && File.Exists(configFile)) - { - _ = configuration.AddConfigFile(configFile, optional: false, reloadOnChange: true); - return configuration; - } - } - - throw new InvalidOperationException("No configuration file found. Please create a devproxyrc.json or devproxyrc.yaml file in the current directory."); - } +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Utils; +using DevProxy.Commands; + +#pragma warning disable IDE0130 +namespace Microsoft.Extensions.Configuration; +#pragma warning restore IDE0130 + +static class ConfigurationManagerExtensions +{ + public static ConfigurationManager ConfigureDevProxyConfig(this ConfigurationManager configuration, DevProxyConfigOptions options) + { + configuration.Sources.Clear(); + _ = configuration.SetBasePath(Directory.GetCurrentDirectory()); + + foreach (var configFile in ProxyUtils.GetConfigFileCandidates(options.ConfigFile)) + { + if (!string.IsNullOrEmpty(configFile) && File.Exists(configFile)) + { + _ = configuration.AddConfigFile(configFile, optional: false, reloadOnChange: true, environmentVariables: options.Env); + return configuration; + } + } + + throw new InvalidOperationException("No configuration file found. Please create a devproxyrc.json or devproxyrc.yaml file in the current directory."); + } } \ No newline at end of file diff --git a/DevProxy/Program.cs b/DevProxy/Program.cs index ec32588f4..6d3dcc5b6 100644 --- a/DevProxy/Program.cs +++ b/DevProxy/Program.cs @@ -1,472 +1,479 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using DevProxy; -using DevProxy.Commands; -using DevProxy.Proxy; -using DevProxy.State; -using System.Diagnostics; -using System.Globalization; -using System.Net; -using System.Runtime.InteropServices; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; - -// Keep management files discoverable even when env overrides change user data paths. -_ = StateManager.GetConfigFolder(); - -// Handle detached mode - spawn a child process and exit -// Only applies to root command (starting the proxy), not subcommands -if (DevProxyCommand.IsRootCommand && - DevProxyCommand.IsDetachedMode && - !DevProxyCommand.IsInternalDaemon) -{ - var detachedExitCode = await StartDetachedProcessAsync(args); - Environment.Exit(detachedExitCode); - return; -} - -// For daemon mode, redirect Console.Out/Error to the log file -// so that the normal console formatters (ProxyConsoleFormatter, -// MachineConsoleFormatter) write to the file instead of stdout. -// Console.IsOutputRedirected becomes true, which automatically -// strips ANSI color codes from human-readable output. -StreamWriter? _detachedLogWriter = null; -if (DevProxyCommand.IsInternalDaemon) -{ - var logFilePath = DevProxyCommand.DetachedLogFilePath; - var logDir = Path.GetDirectoryName(logFilePath); - if (!string.IsNullOrEmpty(logDir)) - { - PrivateFiles.EnsureDirectory(StateManager.GetConfigFolder()); - PrivateFiles.EnsureDirectory(logDir); - } - -#pragma warning disable CA2000 // Lifetime managed manually; disposed before Environment.Exit - _detachedLogWriter = new StreamWriter(logFilePath, append: false, Encoding.UTF8) { AutoFlush = true }; -#pragma warning restore CA2000 - Console.SetOut(_detachedLogWriter); - Console.SetError(_detachedLogWriter); -} - -static async Task StartDetachedProcessAsync(string[] args) -{ - var isJsonOutput = IsJsonOutputRequested(args); - - // Recover from a system-proxy registration left behind by a previous - // instance that crashed before restoring the OS proxy. This must run before - // any state-loading call below, which prunes (deletes) the stale state files - // it depends on without restoring the proxy. - var reconciliation = await SystemProxyManager.ReconcileOrphanedSystemProxiesAsync(); - foreach (var orphan in reconciliation.Orphans) - { - var message = reconciliation.SystemProxyDisabled - ? $"Recovered system proxy left by a crashed Dev Proxy instance (PID: {orphan.Pid})." - : $"Removed stale system-proxy registration left by a crashed Dev Proxy instance (PID: {orphan.Pid}) while keeping the current system proxy owner unchanged."; - if (isJsonOutput) - { - await Console.Out.WriteLineAsync(FormatJsonLogEntry("info", message)); - } - else - { - await Console.Out.WriteLineAsync(message); - } - } - - // Check if an instance is already running as system proxy - var systemProxyInstance = await StateManager.FindSystemProxyInstanceAsync(); - if (systemProxyInstance is not null) - { - if (isJsonOutput) - { - await Console.Error.WriteLineAsync( - FormatJsonLogEntry("error", $"Dev Proxy is already running as system proxy (PID: {systemProxyInstance.Pid}). Use 'devproxy stop' to stop it first.")); - } - else - { - await Console.Error.WriteLineAsync($"Dev Proxy is already running as system proxy (PID: {systemProxyInstance.Pid})."); - await Console.Error.WriteLineAsync("Use 'devproxy stop' to stop it first."); - } - return 1; - } - - // Check for port conflicts with existing instances - var existingStates = await StateManager.LoadAllStatesAsync(); - if (existingStates.Count > 0) - { - var earlyOptions = new DevProxyConfigOptions(); - earlyOptions.ParseOptions(args); - var requestedPort = earlyOptions.Port ?? 8000; - var requestedApiPort = earlyOptions.ApiPort ?? 8897; - - foreach (var existing in existingStates) - { - if (requestedPort != 0 && existing.Port == requestedPort) - { - await Console.Error.WriteLineAsync($"Port {requestedPort} is already in use by Dev Proxy instance (PID: {existing.Pid})."); - await Console.Error.WriteLineAsync("Use a different --port or stop the existing instance first."); - return 1; - } - - if (requestedApiPort != 0 && existing.ApiUrl.EndsWith($":{requestedApiPort}", StringComparison.Ordinal)) - { - await Console.Error.WriteLineAsync($"API port {requestedApiPort} is already in use by Dev Proxy instance (PID: {existing.Pid})."); - await Console.Error.WriteLineAsync("Use a different --api-port or stop the existing instance first."); - return 1; - } - } - } - - // Clean up old log files - StateManager.CleanupOldLogs(); - - // Build the arguments for the daemon process - // Replace --detach/-d with --_internal-daemon - var daemonArgs = args - .Where(a => a is not "--detach" and not "-d") - .Append("--_internal-daemon") - .ToArray(); - - var executablePath = Environment.ProcessPath; - if (string.IsNullOrEmpty(executablePath)) - { - if (isJsonOutput) - { - await Console.Error.WriteLineAsync( - FormatJsonLogEntry("error", "Could not determine executable path.")); - } - else - { - await Console.Error.WriteLineAsync("Could not determine executable path."); - } - return 1; - } - - var startInfo = new ProcessStartInfo - { - FileName = executablePath, - Arguments = string.Join(" ", daemonArgs.Select(EscapeArgument)), - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - WorkingDirectory = Directory.GetCurrentDirectory() - }; - - // On Windows, we need to detach from the console - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - startInfo.WindowStyle = ProcessWindowStyle.Hidden; - } - - try - { - var process = Process.Start(startInfo); - if (process == null) - { - if (isJsonOutput) - { - await Console.Error.WriteLineAsync( - FormatJsonLogEntry("error", "Failed to start Dev Proxy process.")); - } - else - { - await Console.Error.WriteLineAsync("Failed to start Dev Proxy process."); - } - return 1; - } - - // Wait a moment for the proxy to start and write its state file - var timeout = TimeSpan.FromSeconds(10); - var stopwatch = Stopwatch.StartNew(); - - while (stopwatch.Elapsed < timeout) - { - await Task.Delay(200); - - var state = await StateManager.LoadStateByPidAsync(process.Id); - if (state is { Port: > 0 } && !string.IsNullOrEmpty(state.ApiUrl) && !state.ApiUrl.EndsWith(":0", StringComparison.Ordinal)) - { - var proxyUrl = state.ProxyUrl; - var token = ApiSecurity.ShouldDisplayTokenFor(state) ? - (await File.ReadAllTextAsync(ApiSecurity.GetTokenFilePath(state.Pid))).Trim() : null; - - if (isJsonOutput) - { - await Console.Out.WriteLineAsync(FormatJsonResultEntry(new - { - state.Pid, - ProxyUrl = proxyUrl, - state.ApiUrl, - Token = token, - state.LogFile - })); - } - else - { - var showToken = token is not null; - await Console.Out.WriteLineAsync("Dev Proxy started in background."); - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync($" PID: {state.Pid}"); - await Console.Out.WriteLineAsync($" Proxy URL: {proxyUrl}"); - await Console.Out.WriteLineAsync($" API URL: {state.ApiUrl}"); - if (showToken) - { - await Console.Out.WriteLineAsync($" API token: {token}"); - } - await Console.Out.WriteLineAsync($" Log file: {state.LogFile}"); - await Console.Out.WriteLineAsync(); - if (!showToken) - { - await Console.Out.WriteLineAsync($"Use 'devproxy api token --pid {state.Pid}' to get the API token."); - } - await Console.Out.WriteLineAsync("Use 'devproxy status' to check status."); - await Console.Out.WriteLineAsync("Use 'devproxy logs' to view logs."); - await Console.Out.WriteLineAsync("Use 'devproxy stop' to stop."); - } - return 0; - } - - // Check if process exited prematurely - if (process.HasExited) - { - var errorOutput = await process.StandardError.ReadToEndAsync(); - var standardOutput = await process.StandardOutput.ReadToEndAsync(); - - if (isJsonOutput) - { - var message = "Dev Proxy failed to start."; - if (!string.IsNullOrEmpty(errorOutput)) - { - message += $" {errorOutput.Trim()}"; - } - if (!string.IsNullOrEmpty(standardOutput)) - { - message += $" {standardOutput.Trim()}"; - } - await Console.Error.WriteLineAsync(FormatJsonLogEntry("error", message)); - } - else - { - await Console.Error.WriteLineAsync("Dev Proxy failed to start."); - if (!string.IsNullOrEmpty(errorOutput)) - { - await Console.Error.WriteLineAsync(errorOutput); - } - if (!string.IsNullOrEmpty(standardOutput)) - { - await Console.Error.WriteLineAsync(standardOutput); - } - } - return process.ExitCode; - } - } - - if (isJsonOutput) - { - await Console.Error.WriteLineAsync( - FormatJsonLogEntry("error", $"Timeout waiting for Dev Proxy to start. Check the log folder: {StateManager.GetLogsFolder()}")); - } - else - { - await Console.Error.WriteLineAsync("Timeout waiting for Dev Proxy to start."); - await Console.Error.WriteLineAsync($"Check the log folder: {StateManager.GetLogsFolder()}"); - } - return 1; - } - catch (Exception ex) - { - if (isJsonOutput) - { - await Console.Error.WriteLineAsync( - FormatJsonLogEntry("error", $"Failed to start Dev Proxy: {ex.Message}")); - } - else - { - await Console.Error.WriteLineAsync($"Failed to start Dev Proxy: {ex.Message}"); - } - return 1; - } -} - -/// -/// Formats a result entry matching the JSONL envelope produced by JsonConsoleFormatter: -/// {"type":"result","data":{...},"timestamp":"..."} -/// -static string FormatJsonResultEntry(object data) -{ -#pragma warning disable CA1869 // Called at most once per process lifetime - var jsonlOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; -#pragma warning restore CA1869 - var entry = new - { - type = "result", - data = JsonSerializer.SerializeToElement(data, jsonlOptions), - timestamp = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture) - }; - return JsonSerializer.Serialize(entry, jsonlOptions); -} - -/// -/// Formats a log entry matching the JSONL envelope produced by JsonConsoleFormatter: -/// {"type":"log","level":"...","message":"...","timestamp":"..."} -/// -static string FormatJsonLogEntry(string level, string message) -{ -#pragma warning disable CA1869 // Called at most once per process lifetime - var jsonlOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; -#pragma warning restore CA1869 - var entry = new - { - type = "log", - level, - message, - timestamp = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture) - }; - return JsonSerializer.Serialize(entry, jsonlOptions); -} - -static bool IsJsonOutputRequested(string[] args) -{ - for (var i = 0; i < args.Length; i++) - { - if (string.Equals(args[i], DevProxyCommand.OutputOptionName, StringComparison.OrdinalIgnoreCase) && - i + 1 < args.Length) - { - return string.Equals(args[i + 1], "json", StringComparison.OrdinalIgnoreCase); - } - } - return false; -} - -static string EscapeArgument(string arg) -{ - // Simple escaping for command line arguments - if (arg.Contains(' ', StringComparison.Ordinal) || arg.Contains('"', StringComparison.Ordinal)) - { - return $"\"{arg.Replace("\"", "\\\"", StringComparison.Ordinal)}\""; - } - return arg; -} - -static WebApplication BuildApplication( - DevProxyConfigOptions options, ProcessEnvironment environment, HttpProxyEnvironment proxyEnvironment) -{ - // Don't pass command-line args to WebApplication.CreateBuilder because: - // 1. Dev Proxy uses System.CommandLine for CLI parsing, not ASP.NET Core's CommandLineConfigurationProvider - // 2. ConfigureDevProxyConfig clears all configuration sources anyway and only uses JSON config files - var builder = WebApplication.CreateBuilder(); - - _ = builder.Configuration.ConfigureDevProxyConfig(options); - environment.Apply(builder.Configuration, options.Env); - var changedProxyVariables = proxyEnvironment.GetChangedVariables(); - _ = Announcement.ShowOnceAsync(); - _ = builder.Logging.ConfigureDevProxyLogging(builder.Configuration, options); - var allowedOrigins = ApiSecurity.GetAllowedOrigins(builder.Configuration); - _ = builder.Services.ConfigureDevProxyServices(builder.Configuration, options, allowedOrigins); - - var defaultApiPort = 8897; - var apiPort = options.ApiPort ?? - builder.Configuration.GetValue("apiPort", defaultApiPort); - var apiAddressText = options.ApiIpAddress ?? builder.Configuration.GetValue("apiIpAddress", "127.0.0.1"); - if (!IPAddress.TryParse(apiAddressText, out var apiAddress)) - { - throw new InvalidOperationException("apiIpAddress must be an IP address, for example 127.0.0.1 or ::1."); - } - _ = builder.WebHost.ConfigureKestrel(options => - { - options.Listen(apiAddress, apiPort); - }); - - var app = builder.Build(); - - if (changedProxyVariables.Length > 0) - { - app.Logger.LogWarning( - "Proxy-routing environment variables changed ({EnvironmentVariables}). HTTP proxy settings are cached and cannot be refreshed by hot reload. Stop Dev Proxy and start it again to apply these changes.", - string.Join(", ", changedProxyVariables)); - } - - _ = app.Use((context, next) => ApiSecurity.CheckOriginAsync(context, next, allowedOrigins)); - _ = app.UseCors(); - _ = app.Use(ApiSecurity.AuthenticateAsync); - _ = app.MapControllers(); - - return app; -} - -static async Task RunProxyAsync(string[] args, DevProxyConfigOptions options, HttpProxyEnvironment proxyEnvironment) -{ - // Keep overrides through host shutdown, but restore inherited values before a hot reload. - using var environment = new ProcessEnvironment(); - var app = BuildApplication(options, environment, proxyEnvironment); - try - { - var devProxyCommand = app.Services.GetRequiredService(); - return await devProxyCommand.InvokeAsync(args, app); - } - finally - { - await app.DisposeAsync(); - } -} - -// Handle 'config validate' early to allow validation of broken configs -// without requiring the full DI container to be built -if (DevProxyCommand.IsConfigValidateCommand) -{ - _ = Announcement.ShowOnceAsync(); - var validateExitCode = await ConfigCommand.RunValidateStandaloneAsync(args); - Environment.Exit(validateExitCode); - return; -} - -var options = new DevProxyConfigOptions(); -options.ParseOptions(args); - -int exitCode; -bool shouldRestart; -var proxyEnvironment = new HttpProxyEnvironment(); -do -{ - try - { - // Reset the restart flag before each run - ConfigFileWatcher.Reset(); - // RunProxyAsync only returns after the host has fully stopped, which - // means the proxy engine's shutdown (including system proxy - // deregistration) has completed — so it's safe to start a new instance. - exitCode = await RunProxyAsync(args, options, proxyEnvironment); - - shouldRestart = ConfigFileWatcher.IsRestarting; - } - catch (Exception ex) - { - await Console.Error.WriteLineAsync("Unhandled exception during proxy run. Stopping restart loop."); - await Console.Error.WriteLineAsync(ex.ToString()); - exitCode = 1; - shouldRestart = false; - } -} while (shouldRestart); - -if (DevProxyCommand.IsRootCommand) -{ - await StateManager.DeleteStateAsync(); -} - -if (_detachedLogWriter is not null) -{ - await _detachedLogWriter.DisposeAsync(); -} - +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy; +using DevProxy.Abstractions.Utils; +using DevProxy.Commands; +using DevProxy.Proxy; +using DevProxy.State; +using System.Diagnostics; +using System.Globalization; +using System.Net; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +// Keep management files discoverable even when env overrides change user data paths. +_ = StateManager.GetConfigFolder(); + +// Handle detached mode - spawn a child process and exit +// Only applies to root command (starting the proxy), not subcommands +if (DevProxyCommand.IsRootCommand && + DevProxyCommand.IsDetachedMode && + !DevProxyCommand.IsInternalDaemon) +{ + var detachedExitCode = await StartDetachedProcessAsync(args); + Environment.Exit(detachedExitCode); + return; +} + +// For daemon mode, redirect Console.Out/Error to the log file +// so that the normal console formatters (ProxyConsoleFormatter, +// MachineConsoleFormatter) write to the file instead of stdout. +// Console.IsOutputRedirected becomes true, which automatically +// strips ANSI color codes from human-readable output. +StreamWriter? _detachedLogWriter = null; +if (DevProxyCommand.IsInternalDaemon) +{ + var logFilePath = DevProxyCommand.DetachedLogFilePath; + var logDir = Path.GetDirectoryName(logFilePath); + if (!string.IsNullOrEmpty(logDir)) + { + PrivateFiles.EnsureDirectory(StateManager.GetConfigFolder()); + PrivateFiles.EnsureDirectory(logDir); + } + +#pragma warning disable CA2000 // Lifetime managed manually; disposed before Environment.Exit + _detachedLogWriter = new StreamWriter(logFilePath, append: false, Encoding.UTF8) { AutoFlush = true }; +#pragma warning restore CA2000 + Console.SetOut(_detachedLogWriter); + Console.SetError(_detachedLogWriter); +} + +static async Task StartDetachedProcessAsync(string[] args) +{ + var isJsonOutput = IsJsonOutputRequested(args); + + // Recover from a system-proxy registration left behind by a previous + // instance that crashed before restoring the OS proxy. This must run before + // any state-loading call below, which prunes (deletes) the stale state files + // it depends on without restoring the proxy. + var reconciliation = await SystemProxyManager.ReconcileOrphanedSystemProxiesAsync(); + foreach (var orphan in reconciliation.Orphans) + { + var message = reconciliation.SystemProxyDisabled + ? $"Recovered system proxy left by a crashed Dev Proxy instance (PID: {orphan.Pid})." + : $"Removed stale system-proxy registration left by a crashed Dev Proxy instance (PID: {orphan.Pid}) while keeping the current system proxy owner unchanged."; + if (isJsonOutput) + { + await Console.Out.WriteLineAsync(FormatJsonLogEntry("info", message)); + } + else + { + await Console.Out.WriteLineAsync(message); + } + } + + // Check if an instance is already running as system proxy + var systemProxyInstance = await StateManager.FindSystemProxyInstanceAsync(); + if (systemProxyInstance is not null) + { + if (isJsonOutput) + { + await Console.Error.WriteLineAsync( + FormatJsonLogEntry("error", $"Dev Proxy is already running as system proxy (PID: {systemProxyInstance.Pid}). Use 'devproxy stop' to stop it first.")); + } + else + { + await Console.Error.WriteLineAsync($"Dev Proxy is already running as system proxy (PID: {systemProxyInstance.Pid})."); + await Console.Error.WriteLineAsync("Use 'devproxy stop' to stop it first."); + } + return 1; + } + + // Check for port conflicts with existing instances + var existingStates = await StateManager.LoadAllStatesAsync(); + if (existingStates.Count > 0) + { + var earlyOptions = new DevProxyConfigOptions(); + earlyOptions.ParseOptions(args); + var requestedPort = earlyOptions.Port ?? 8000; + var requestedApiPort = earlyOptions.ApiPort ?? 8897; + + foreach (var existing in existingStates) + { + if (requestedPort != 0 && existing.Port == requestedPort) + { + await Console.Error.WriteLineAsync($"Port {requestedPort} is already in use by Dev Proxy instance (PID: {existing.Pid})."); + await Console.Error.WriteLineAsync("Use a different --port or stop the existing instance first."); + return 1; + } + + if (requestedApiPort != 0 && existing.ApiUrl.EndsWith($":{requestedApiPort}", StringComparison.Ordinal)) + { + await Console.Error.WriteLineAsync($"API port {requestedApiPort} is already in use by Dev Proxy instance (PID: {existing.Pid})."); + await Console.Error.WriteLineAsync("Use a different --api-port or stop the existing instance first."); + return 1; + } + } + } + + // Clean up old log files + StateManager.CleanupOldLogs(); + + // Build the arguments for the daemon process + // Replace --detach/-d with --_internal-daemon + var daemonArgs = args + .Where(a => a is not "--detach" and not "-d") + .Append("--_internal-daemon") + .ToArray(); + + var executablePath = Environment.ProcessPath; + if (string.IsNullOrEmpty(executablePath)) + { + if (isJsonOutput) + { + await Console.Error.WriteLineAsync( + FormatJsonLogEntry("error", "Could not determine executable path.")); + } + else + { + await Console.Error.WriteLineAsync("Could not determine executable path."); + } + return 1; + } + + var startInfo = new ProcessStartInfo + { + FileName = executablePath, + Arguments = string.Join(" ", daemonArgs.Select(EscapeArgument)), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = Directory.GetCurrentDirectory() + }; + + // On Windows, we need to detach from the console + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + startInfo.WindowStyle = ProcessWindowStyle.Hidden; + } + + try + { + var process = Process.Start(startInfo); + if (process == null) + { + if (isJsonOutput) + { + await Console.Error.WriteLineAsync( + FormatJsonLogEntry("error", "Failed to start Dev Proxy process.")); + } + else + { + await Console.Error.WriteLineAsync("Failed to start Dev Proxy process."); + } + return 1; + } + + // Wait a moment for the proxy to start and write its state file + var timeout = TimeSpan.FromSeconds(10); + var stopwatch = Stopwatch.StartNew(); + + while (stopwatch.Elapsed < timeout) + { + await Task.Delay(200); + + var state = await StateManager.LoadStateByPidAsync(process.Id); + if (state is { Port: > 0 } && !string.IsNullOrEmpty(state.ApiUrl) && !state.ApiUrl.EndsWith(":0", StringComparison.Ordinal)) + { + var proxyUrl = state.ProxyUrl; + var token = ApiSecurity.ShouldDisplayTokenFor(state) ? + (await File.ReadAllTextAsync(ApiSecurity.GetTokenFilePath(state.Pid))).Trim() : null; + + if (isJsonOutput) + { + await Console.Out.WriteLineAsync(FormatJsonResultEntry(new + { + state.Pid, + ProxyUrl = proxyUrl, + state.ApiUrl, + Token = token, + state.LogFile + })); + } + else + { + var showToken = token is not null; + await Console.Out.WriteLineAsync("Dev Proxy started in background."); + await Console.Out.WriteLineAsync(); + await Console.Out.WriteLineAsync($" PID: {state.Pid}"); + await Console.Out.WriteLineAsync($" Proxy URL: {proxyUrl}"); + await Console.Out.WriteLineAsync($" API URL: {state.ApiUrl}"); + if (showToken) + { + await Console.Out.WriteLineAsync($" API token: {token}"); + } + await Console.Out.WriteLineAsync($" Log file: {state.LogFile}"); + await Console.Out.WriteLineAsync(); + if (!showToken) + { + await Console.Out.WriteLineAsync($"Use 'devproxy api token --pid {state.Pid}' to get the API token."); + } + await Console.Out.WriteLineAsync("Use 'devproxy status' to check status."); + await Console.Out.WriteLineAsync("Use 'devproxy logs' to view logs."); + await Console.Out.WriteLineAsync("Use 'devproxy stop' to stop."); + } + return 0; + } + + // Check if process exited prematurely + if (process.HasExited) + { + var errorOutput = await process.StandardError.ReadToEndAsync(); + var standardOutput = await process.StandardOutput.ReadToEndAsync(); + + if (isJsonOutput) + { + var message = "Dev Proxy failed to start."; + if (!string.IsNullOrEmpty(errorOutput)) + { + message += $" {errorOutput.Trim()}"; + } + if (!string.IsNullOrEmpty(standardOutput)) + { + message += $" {standardOutput.Trim()}"; + } + await Console.Error.WriteLineAsync(FormatJsonLogEntry("error", message)); + } + else + { + await Console.Error.WriteLineAsync("Dev Proxy failed to start."); + if (!string.IsNullOrEmpty(errorOutput)) + { + await Console.Error.WriteLineAsync(errorOutput); + } + if (!string.IsNullOrEmpty(standardOutput)) + { + await Console.Error.WriteLineAsync(standardOutput); + } + } + return process.ExitCode; + } + } + + if (isJsonOutput) + { + await Console.Error.WriteLineAsync( + FormatJsonLogEntry("error", $"Timeout waiting for Dev Proxy to start. Check the log folder: {StateManager.GetLogsFolder()}")); + } + else + { + await Console.Error.WriteLineAsync("Timeout waiting for Dev Proxy to start."); + await Console.Error.WriteLineAsync($"Check the log folder: {StateManager.GetLogsFolder()}"); + } + return 1; + } + catch (Exception ex) + { + if (isJsonOutput) + { + await Console.Error.WriteLineAsync( + FormatJsonLogEntry("error", $"Failed to start Dev Proxy: {ex.Message}")); + } + else + { + await Console.Error.WriteLineAsync($"Failed to start Dev Proxy: {ex.Message}"); + } + return 1; + } +} + +/// +/// Formats a result entry matching the JSONL envelope produced by JsonConsoleFormatter: +/// {"type":"result","data":{...},"timestamp":"..."} +/// +static string FormatJsonResultEntry(object data) +{ +#pragma warning disable CA1869 // Called at most once per process lifetime + var jsonlOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; +#pragma warning restore CA1869 + var entry = new + { + type = "result", + data = JsonSerializer.SerializeToElement(data, jsonlOptions), + timestamp = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture) + }; + return JsonSerializer.Serialize(entry, jsonlOptions); +} + +/// +/// Formats a log entry matching the JSONL envelope produced by JsonConsoleFormatter: +/// {"type":"log","level":"...","message":"...","timestamp":"..."} +/// +static string FormatJsonLogEntry(string level, string message) +{ +#pragma warning disable CA1869 // Called at most once per process lifetime + var jsonlOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; +#pragma warning restore CA1869 + var entry = new + { + type = "log", + level, + message, + timestamp = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture) + }; + return JsonSerializer.Serialize(entry, jsonlOptions); +} + +static bool IsJsonOutputRequested(string[] args) +{ + for (var i = 0; i < args.Length; i++) + { + if (string.Equals(args[i], DevProxyCommand.OutputOptionName, StringComparison.OrdinalIgnoreCase) && + i + 1 < args.Length) + { + return string.Equals(args[i + 1], "json", StringComparison.OrdinalIgnoreCase); + } + } + return false; +} + +static string EscapeArgument(string arg) +{ + // Simple escaping for command line arguments + if (arg.Contains(' ', StringComparison.Ordinal) || arg.Contains('"', StringComparison.Ordinal)) + { + return $"\"{arg.Replace("\"", "\\\"", StringComparison.Ordinal)}\""; + } + return arg; +} + +static WebApplication BuildApplication( + DevProxyConfigOptions options, ProcessEnvironment environment, HttpProxyEnvironment proxyEnvironment) +{ + // Don't pass command-line args to WebApplication.CreateBuilder because: + // 1. Dev Proxy uses System.CommandLine for CLI parsing, not ASP.NET Core's CommandLineConfigurationProvider + // 2. ConfigureDevProxyConfig clears all configuration sources anyway and only uses JSON config files + var builder = WebApplication.CreateBuilder(); + + _ = builder.Configuration.ConfigureDevProxyConfig(options); + environment.Apply(builder.Configuration, options.Env); + var changedProxyVariables = proxyEnvironment.GetChangedVariables(); + _ = Announcement.ShowOnceAsync(); + _ = builder.Logging.ConfigureDevProxyLogging(builder.Configuration, options); + var allowedOrigins = ApiSecurity.GetAllowedOrigins(builder.Configuration); + _ = builder.Services.ConfigureDevProxyServices(builder.Configuration, options, allowedOrigins); + + var defaultApiPort = 8897; + var apiPort = options.ApiPort ?? + builder.Configuration.GetValue("apiPort", defaultApiPort); + var apiAddressText = options.ApiIpAddress ?? builder.Configuration.GetValue("apiIpAddress", "127.0.0.1"); + if (!IPAddress.TryParse(apiAddressText, out var apiAddress)) + { + throw new InvalidOperationException("apiIpAddress must be an IP address, for example 127.0.0.1 or ::1."); + } + _ = builder.WebHost.ConfigureKestrel(options => + { + options.Listen(apiAddress, apiPort); + }); + + var app = builder.Build(); + + foreach (var provider in ((IConfigurationRoot)builder.Configuration).Providers.OfType()) + { + EnvironmentVariablePlaceholders.LogUnresolvedVariables( + app.Logger, provider.UnresolvedEnvironmentVariables, provider.Path ?? "configuration file"); + } + + if (changedProxyVariables.Length > 0) + { + app.Logger.LogWarning( + "Proxy-routing environment variables changed ({EnvironmentVariables}). HTTP proxy settings are cached and cannot be refreshed by hot reload. Stop Dev Proxy and start it again to apply these changes.", + string.Join(", ", changedProxyVariables)); + } + + _ = app.Use((context, next) => ApiSecurity.CheckOriginAsync(context, next, allowedOrigins)); + _ = app.UseCors(); + _ = app.Use(ApiSecurity.AuthenticateAsync); + _ = app.MapControllers(); + + return app; +} + +static async Task RunProxyAsync(string[] args, DevProxyConfigOptions options, HttpProxyEnvironment proxyEnvironment) +{ + // Keep overrides through host shutdown, but restore inherited values before a hot reload. + using var environment = new ProcessEnvironment(); + var app = BuildApplication(options, environment, proxyEnvironment); + try + { + var devProxyCommand = app.Services.GetRequiredService(); + return await devProxyCommand.InvokeAsync(args, app); + } + finally + { + await app.DisposeAsync(); + } +} + +// Handle 'config validate' early to allow validation of broken configs +// without requiring the full DI container to be built +if (DevProxyCommand.IsConfigValidateCommand) +{ + _ = Announcement.ShowOnceAsync(); + var validateExitCode = await ConfigCommand.RunValidateStandaloneAsync(args); + Environment.Exit(validateExitCode); + return; +} + +var options = new DevProxyConfigOptions(); +options.ParseOptions(args); + +int exitCode; +bool shouldRestart; +var proxyEnvironment = new HttpProxyEnvironment(); +do +{ + try + { + // Reset the restart flag before each run + ConfigFileWatcher.Reset(); + // RunProxyAsync only returns after the host has fully stopped, which + // means the proxy engine's shutdown (including system proxy + // deregistration) has completed — so it's safe to start a new instance. + exitCode = await RunProxyAsync(args, options, proxyEnvironment); + + shouldRestart = ConfigFileWatcher.IsRestarting; + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync("Unhandled exception during proxy run. Stopping restart loop."); + await Console.Error.WriteLineAsync(ex.ToString()); + exitCode = 1; + shouldRestart = false; + } +} while (shouldRestart); + +if (DevProxyCommand.IsRootCommand) +{ + await StateManager.DeleteStateAsync(); +} + +if (_detachedLogWriter is not null) +{ + await _detachedLogWriter.DisposeAsync(); +} + Environment.Exit(exitCode); \ No newline at end of file diff --git a/schemas/v4.0.0/rc.schema.json b/schemas/v4.0.0/rc.schema.json index 4db88db85..796ce5227 100644 --- a/schemas/v4.0.0/rc.schema.json +++ b/schemas/v4.0.0/rc.schema.json @@ -37,7 +37,7 @@ }, "env": { "type": "object", - "description": "Environment variables to set for the Dev Proxy process. Values are also used to replace {name} placeholders in API spec files used by the MinimalPermissions plugins. Values passed with --env replace these. Changes to HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, or NO_PROXY (including lowercase variants) require a full stop/start; hot reload cannot refresh cached HTTP proxy settings.", + "description": "Environment variables to set for the Dev Proxy process. Values can be referenced as ${NAME} placeholders in this config file and in plugin data files. Values are also used to replace {name} placeholders in API spec files used by the MinimalPermissions plugins (deprecated; use ${NAME} instead). Values passed with --env replace these. Changes to HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, or NO_PROXY (including lowercase variants) require a full stop/start; hot reload cannot refresh cached HTTP proxy settings.", "propertyNames": { "minLength": 1, "pattern": "^[^=]+$" diff --git a/skills/dev-proxy/references/analyze-api-usage.md b/skills/dev-proxy/references/analyze-api-usage.md index 95bf4368f..231950558 100644 --- a/skills/dev-proxy/references/analyze-api-usage.md +++ b/skills/dev-proxy/references/analyze-api-usage.md @@ -282,15 +282,15 @@ Use `ApiCenterOnboardingPlugin` to check whether the APIs an app uses are regist } ``` -For CI/CD, use environment variables with `@` prefix: +For CI/CD, reference environment variables with `${VAR_NAME}` (the older `@VAR_NAME` syntax is deprecated): ```json { "apiCenterOnboardingPlugin": { - "subscriptionId": "@AZURE_SUBSCRIPTION_ID", - "resourceGroupName": "@AZURE_RESOURCE_GROUP_NAME", - "serviceName": "@AZURE_APIC_INSTANCE_NAME", - "workspace": "@AZURE_APIC_WORKSPACE_NAME", + "subscriptionId": "${AZURE_SUBSCRIPTION_ID}", + "resourceGroupName": "${AZURE_RESOURCE_GROUP_NAME}", + "serviceName": "${AZURE_APIC_INSTANCE_NAME}", + "workspace": "${AZURE_APIC_WORKSPACE_NAME}", "createApicEntryForNewApis": false } } diff --git a/skills/dev-proxy/references/ci-cd-integration.md b/skills/dev-proxy/references/ci-cd-integration.md index f3a98d222..48a0c2fe0 100644 --- a/skills/dev-proxy/references/ci-cd-integration.md +++ b/skills/dev-proxy/references/ci-cd-integration.md @@ -380,14 +380,14 @@ Run `devproxy api show` for endpoint information. All endpoints require `Authori ## Environment Variables for Azure API Center -Azure API Center plugins support `@ENV_VAR` syntax for CI/CD: +Reference environment variables in config files with `${ENV_VAR}` (the older `@ENV_VAR` syntax is deprecated): ```json { "apiCenterOnboardingPlugin": { - "subscriptionId": "@AZURE_SUBSCRIPTION_ID", - "resourceGroupName": "@AZURE_RESOURCE_GROUP_NAME", - "serviceName": "@AZURE_APIC_INSTANCE_NAME" + "subscriptionId": "${AZURE_SUBSCRIPTION_ID}", + "resourceGroupName": "${AZURE_RESOURCE_GROUP_NAME}", + "serviceName": "${AZURE_APIC_INSTANCE_NAME}" } } ``` diff --git a/skills/dev-proxy/references/configuration.md b/skills/dev-proxy/references/configuration.md index 7cbd22228..e20f26005 100644 --- a/skills/dev-proxy/references/configuration.md +++ b/skills/dev-proxy/references/configuration.md @@ -46,6 +46,28 @@ A configuration file follows a specific property order: `$schema`, then `plugins - File paths in Dev Proxy configuration files are relative to the file where they are defined, unless they start with `~/` (or `~\` on Windows), which expands to the user's home directory. - Configuration hot-reloads on file save (v2.1.0+) — no restart needed. Works for both the main config and plugin-specific files. +## Environment Variable Placeholders + +Reference environment variables in config files and plugin data files (mocks, CRUD API definitions and data, error responses, OpenAPI specs used by MinimalPermissions plugins, and so on) with `${VAR_NAME}`. `${env:VAR_NAME}` works too. Dev Proxy resolves placeholders when it loads the file. + +```json +{ + "entraAuthConfig": { + "audience": "${ENTRA_APP_CLIENT_ID}", + "issuer": "https://login.microsoftonline.com/${ENTRA_APP_TENANT_ID}/v2.0" + } +} +``` + +- In the config file, variables resolve from `--env` values first, then the config `env` section, then the OS environment. Values in the `env` section can themselves reference OS environment variables or `--env` values. +- Plugin data files resolve from the process environment, which includes the `env` section and `--env` values. +- If a variable isn't set, Dev Proxy leaves the placeholder as-is and logs a warning. +- Use `$${VAR_NAME}` for a literal `${VAR_NAME}`. +- Placeholders resolve in string values only (not property names). Config values like ports can use a string placeholder (`"port": "${PORT}"`); data files need the value's expected type. +- `${...}` placeholders resolve once at load; `@`-tokens such as `@dynamic` and `@request.body.*` resolve per request. +- Exception: RewritePlugin rewrites files don't resolve placeholders at load because `${name}` in `out.url` refers to regex capture groups. Use `${env:VAR_NAME}`, which RewritePlugin resolves per request. +- Deprecated: `@VAR_NAME` in Azure API Center plugin settings and `{VAR_NAME}` in OpenAPI specs used by MinimalPermissions plugins still work but log a warning. Use `${VAR_NAME}` instead. + ## Plugin Architecture Dev Proxy has four plugin types: From dcf3642703e1dd9bd613ef27b2ec0905e66ee292 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 10:18:51 +0100 Subject: [PATCH 2/7] Address review feedback on environment variable placeholders - Don't throw on --env names differing only in casing on Windows - Resolve JSON values containing Unicode-escaped placeholders - Resolve YAML API specs per scalar value instead of raw text - Apply legacy {NAME} variables only outside ${NAME} placeholders Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ConfigurationPlaceholderTests.cs | 12 ++ .../EnvironmentVariablePlaceholdersTests.cs | 62 ++++++- ...ironmentVariableConfigurationExtensions.cs | 13 +- .../Utils/EnvironmentVariablePlaceholders.cs | 175 +++++++++++++++--- .../MinimalPermissionsUtilsTests.cs | 104 +++++++++++ .../Utils/MinimalPermissionsUtils.cs | 30 ++- 6 files changed, 359 insertions(+), 37 deletions(-) create mode 100644 DevProxy.Integration.Tests/MinimalPermissionsUtilsTests.cs diff --git a/DevProxy.Abstractions.Tests/Extensions/ConfigurationPlaceholderTests.cs b/DevProxy.Abstractions.Tests/Extensions/ConfigurationPlaceholderTests.cs index f2f963997..a334f8c93 100644 --- a/DevProxy.Abstractions.Tests/Extensions/ConfigurationPlaceholderTests.cs +++ b/DevProxy.Abstractions.Tests/Extensions/ConfigurationPlaceholderTests.cs @@ -66,6 +66,18 @@ public void AddConfigFile_CliVariablesOverrideEnvSection() Assert.Equal("cli", config["value"]); } + [Fact] + public void AddConfigFile_CliVariablesDifferingInCasing_LastValueWins() + { + var path = Write("devproxyrc.json", """ + { "value": "${NAME}" } + """); + + var (config, _) = Build(path, new Dictionary { ["NAME"] = "first", ["name"] = "second" }); + + Assert.Equal(OperatingSystem.IsWindows() ? "second" : "first", config["value"]); + } + [Fact] public void AddConfigFile_Yaml_ResolvesPlaceholders() { diff --git a/DevProxy.Abstractions.Tests/Utils/EnvironmentVariablePlaceholdersTests.cs b/DevProxy.Abstractions.Tests/Utils/EnvironmentVariablePlaceholdersTests.cs index edc5139d3..9f9d5be91 100644 --- a/DevProxy.Abstractions.Tests/Utils/EnvironmentVariablePlaceholdersTests.cs +++ b/DevProxy.Abstractions.Tests/Utils/EnvironmentVariablePlaceholdersTests.cs @@ -83,4 +83,64 @@ public void ResolveInJson_ReturnsOriginalWhenNoPlaceholders() Assert.Same(json, EnvironmentVariablePlaceholders.ResolveInJson(json, Lookup)); } -} + + [Fact] + public void ResolveInJson_ResolvesUnicodeEscapedPlaceholders() + { + var json = """{ "audience": "\u0024{CLIENT_ID}" }"""; + + var result = EnvironmentVariablePlaceholders.ResolveInJson(json, Lookup); + + using var doc = JsonDocument.Parse(result); + Assert.Equal("1234", doc.RootElement.GetProperty("audience").GetString()); + } + + [Fact] + public void ResolveInJson_ReturnsOriginalWhenEscapesWithoutPlaceholders() + { + var json = """{ "a": "\u00e9" }"""; + + Assert.Same(json, EnvironmentVariablePlaceholders.ResolveInJson(json, Lookup)); + } + + [Fact] + public void ResolveInYaml_ResolvesScalarValuesOnly() + { + var yaml = """ + ${TENANT_ID}: "${TENANT_ID}" + quoted: "${QUOTED}" + base: &base + id: ${CLIENT_ID} + copy: *base + """; + + var result = EnvironmentVariablePlaceholders.ResolveInYaml(yaml, Lookup); + + var stream = new YamlDotNet.RepresentationModel.YamlStream(); + stream.Load(new StringReader(result)); + var root = (YamlDotNet.RepresentationModel.YamlMappingNode)stream.Documents[0].RootNode; + Assert.Equal("contoso", root["${TENANT_ID}"].ToString()); + Assert.Equal("a \"quoted\" \\ value", root["quoted"].ToString()); + Assert.Equal("1234", ((YamlDotNet.RepresentationModel.YamlMappingNode)root["copy"])["id"].ToString()); + } + + [Fact] + public void ResolveInYaml_ReturnsOriginalWhenNoPlaceholders() + { + var yaml = "a: b # keep"; + + Assert.Same(yaml, EnvironmentVariablePlaceholders.ResolveInYaml(yaml, Lookup)); + } + + [Fact] + public void Resolve_TransformsOnlyTextOutsidePlaceholders() + { + var result = EnvironmentVariablePlaceholders.Resolve( + "{TENANT_ID} $${TENANT_ID} ${CLIENT_ID} ${MISSING}", + name => name == "CLIENT_ID" ? "{TENANT_ID}" : Lookup(name), + null, + text => text.Replace("{TENANT_ID}", "legacy", StringComparison.Ordinal)); + + Assert.Equal("legacy ${TENANT_ID} {TENANT_ID} ${MISSING}", result); + } +} \ No newline at end of file diff --git a/DevProxy.Abstractions/Extensions/EnvironmentVariableConfigurationExtensions.cs b/DevProxy.Abstractions/Extensions/EnvironmentVariableConfigurationExtensions.cs index 688e1b2a2..9d1416cff 100644 --- a/DevProxy.Abstractions/Extensions/EnvironmentVariableConfigurationExtensions.cs +++ b/DevProxy.Abstractions/Extensions/EnvironmentVariableConfigurationExtensions.cs @@ -87,9 +87,16 @@ public static IReadOnlyCollection Resolve(IDictionary d ArgumentNullException.ThrowIfNull(data); var unresolved = new List(); - var overrides = environmentVariables is null - ? null - : new Dictionary(environmentVariables, VariableNameComparer); + Dictionary? overrides = null; + if (environmentVariables is not null) + { + overrides = new(VariableNameComparer); + // use the indexer so that names differing only in casing on Windows don't throw; last value wins + foreach (var (name, value) in environmentVariables) + { + overrides[name] = value; + } + } string? baseLookup(string name) { diff --git a/DevProxy.Abstractions/Utils/EnvironmentVariablePlaceholders.cs b/DevProxy.Abstractions/Utils/EnvironmentVariablePlaceholders.cs index 9764dd891..f1f0b26ce 100644 --- a/DevProxy.Abstractions/Utils/EnvironmentVariablePlaceholders.cs +++ b/DevProxy.Abstractions/Utils/EnvironmentVariablePlaceholders.cs @@ -3,10 +3,12 @@ // See the LICENSE file in the project root for more information. using Microsoft.Extensions.Logging; +using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.RegularExpressions; +using YamlDotNet.RepresentationModel; namespace DevProxy.Abstractions.Utils; @@ -41,49 +43,95 @@ public static string Resolve(string value, ICollection? unresolvedVariab /// Value to resolve. /// Function that returns the value of a variable or null if it isn't set. /// Collects names of variables that couldn't be resolved. - public static string Resolve(string value, Func lookup, ICollection? unresolvedVariables = null) + public static string Resolve(string value, Func lookup, ICollection? unresolvedVariables = null) => + Resolve(value, lookup, unresolvedVariables, null); + + /// + /// Resolves placeholders in a string value using the specified lookup. + /// + /// Value to resolve. + /// Function that returns the value of a variable or null if it isn't set. + /// Collects names of variables that couldn't be resolved. + /// Optional transformation applied only to the text outside placeholders. + public static string Resolve(string value, Func lookup, ICollection? unresolvedVariables, Func? transformText) { ArgumentNullException.ThrowIfNull(value); ArgumentNullException.ThrowIfNull(lookup); + if (transformText is not null) + { + return ResolveWithTextTransform(value, lookup, unresolvedVariables, transformText); + } + if (!value.Contains("${", StringComparison.Ordinal)) { return value; } - return PlaceholderRegex().Replace(value, match => + return PlaceholderRegex().Replace(value, match => ResolveMatch(match, lookup, unresolvedVariables)); + } + + private static string ResolveWithTextTransform(string value, Func lookup, ICollection? unresolvedVariables, Func transformText) + { + var result = new StringBuilder(); + var position = 0; + foreach (Match match in PlaceholderRegex().Matches(value)) { - var name = match.Groups["name"].Value; - if (match.Groups["escape"].Success) - { - // $${NAME} -> literal ${NAME} - return match.Value[1..]; - } + _ = result + .Append(transformText(value[position..match.Index])) + .Append(ResolveMatch(match, lookup, unresolvedVariables)); + position = match.Index + match.Length; + } + _ = result.Append(transformText(value[position..])); + return result.ToString(); + } - var variableValue = lookup(name); - if (variableValue is null) + private static string ResolveMatch(Match match, Func lookup, ICollection? unresolvedVariables) + { + var name = match.Groups["name"].Value; + if (match.Groups["escape"].Success) + { + // $${NAME} -> literal ${NAME} + return match.Value[1..]; + } + + var variableValue = lookup(name); + if (variableValue is null) + { + if (unresolvedVariables is not null && !unresolvedVariables.Contains(name)) { - if (unresolvedVariables is not null && !unresolvedVariables.Contains(name)) - { - unresolvedVariables.Add(name); - } - return match.Value; + unresolvedVariables.Add(name); } + return match.Value; + } - return variableValue; - }); + return variableValue; } /// /// Resolves placeholders in all string values of a JSON document. Property names aren't changed. /// Returns the original string if it doesn't contain any placeholders. /// - public static string ResolveInJson(string json, Func lookup, ICollection? unresolvedVariables = null) + public static string ResolveInJson(string json, Func lookup, ICollection? unresolvedVariables = null) => + ResolveInJson(json, lookup, unresolvedVariables, null); + + /// + /// Resolves placeholders in all string values of a JSON document. Property names aren't changed. + /// Returns the original string if no values changed. + /// + /// JSON document. + /// Function that returns the value of a variable or null if it isn't set. + /// Collects names of variables that couldn't be resolved. + /// Optional transformation applied only to the text outside placeholders. + public static string ResolveInJson(string json, Func lookup, ICollection? unresolvedVariables, Func? transformText) { ArgumentNullException.ThrowIfNull(json); ArgumentNullException.ThrowIfNull(lookup); - if (!ContainsPlaceholder(json)) + // string values may contain JSON escapes (eg. \u0024{NAME}) so only skip parsing when there are none + if (transformText is null && + !ContainsPlaceholder(json) && + !json.Contains("\\u", StringComparison.Ordinal)) { return json; } @@ -94,8 +142,14 @@ public static string ResolveInJson(string json, Func lookup, IC return json; } - node = ResolveInJsonNode(node, lookup, unresolvedVariables); - return node.ToJsonString(_writeOptions); + var changed = false; + node = ResolveInJsonNode(node, value => + { + var resolved = Resolve(value, lookup, unresolvedVariables, transformText); + changed |= !string.Equals(value, resolved, StringComparison.Ordinal); + return resolved; + }); + return changed ? node.ToJsonString(_writeOptions) : json; } /// @@ -104,6 +158,77 @@ public static string ResolveInJson(string json, Func lookup, IC public static string ResolveInJson(string json, ICollection? unresolvedVariables = null) => ResolveInJson(json, Environment.GetEnvironmentVariable, unresolvedVariables); + /// + /// Resolves placeholders in all scalar values of a YAML document. Mapping keys aren't changed. + /// Returns the original string if no values changed. + /// + /// YAML document. + /// Function that returns the value of a variable or null if it isn't set. + /// Collects names of variables that couldn't be resolved. + /// Optional transformation applied only to the text outside placeholders. + public static string ResolveInYaml(string yaml, Func lookup, ICollection? unresolvedVariables = null, Func? transformText = null) + { + ArgumentNullException.ThrowIfNull(yaml); + ArgumentNullException.ThrowIfNull(lookup); + + var stream = new YamlStream(); + using (var reader = new StringReader(yaml)) + { + stream.Load(reader); + } + + var changed = false; + var visited = new HashSet(ReferenceEqualityComparer.Instance); + void resolveNode(YamlNode node) + { + // aliases point to the same node instance so each node is resolved once + if (!visited.Add(node)) + { + return; + } + + switch (node) + { + case YamlScalarNode scalar when scalar.Value is not null: + var resolved = Resolve(scalar.Value, lookup, unresolvedVariables, transformText); + if (!string.Equals(scalar.Value, resolved, StringComparison.Ordinal)) + { + scalar.Value = resolved; + changed = true; + } + break; + case YamlMappingNode mapping: + foreach (var child in mapping.Children) + { + resolveNode(child.Value); + } + break; + case YamlSequenceNode sequence: + foreach (var child in sequence.Children) + { + resolveNode(child); + } + break; + default: + break; + } + } + + foreach (var document in stream.Documents) + { + resolveNode(document.RootNode); + } + + if (!changed) + { + return yaml; + } + + using var writer = new StringWriter(); + stream.Save(writer, assignAnchors: false); + return writer.ToString(); + } + /// /// Logs a warning for each environment variable that couldn't be resolved. /// @@ -120,7 +245,7 @@ public static void LogUnresolvedVariables(ILogger logger, IEnumerable un } } - private static JsonNode ResolveInJsonNode(JsonNode node, Func lookup, ICollection? unresolvedVariables) + private static JsonNode ResolveInJsonNode(JsonNode node, Func resolve) { switch (node) { @@ -129,7 +254,7 @@ private static JsonNode ResolveInJsonNode(JsonNode node, Func l { if (property.Value is not null) { - var resolved = ResolveInJsonNode(property.Value, lookup, unresolvedVariables); + var resolved = ResolveInJsonNode(property.Value, resolve); if (!ReferenceEquals(resolved, property.Value)) { obj[property.Key] = resolved; @@ -143,7 +268,7 @@ private static JsonNode ResolveInJsonNode(JsonNode node, Func l var item = array[i]; if (item is not null) { - var resolved = ResolveInJsonNode(item, lookup, unresolvedVariables); + var resolved = ResolveInJsonNode(item, resolve); if (!ReferenceEquals(resolved, item)) { array[i] = resolved; @@ -153,7 +278,7 @@ private static JsonNode ResolveInJsonNode(JsonNode node, Func l return array; case JsonValue value when value.GetValueKind() == JsonValueKind.String: var s = value.GetValue(); - var r = Resolve(s, lookup, unresolvedVariables); + var r = resolve(s); return string.Equals(s, r, StringComparison.Ordinal) ? value : JsonValue.Create(r); default: return node; diff --git a/DevProxy.Integration.Tests/MinimalPermissionsUtilsTests.cs b/DevProxy.Integration.Tests/MinimalPermissionsUtilsTests.cs new file mode 100644 index 000000000..92c9850c6 --- /dev/null +++ b/DevProxy.Integration.Tests/MinimalPermissionsUtilsTests.cs @@ -0,0 +1,104 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Plugins.Utils; +using Microsoft.Extensions.Logging; +using System.Text.Json; +using Xunit; +using YamlDotNet.RepresentationModel; + +namespace DevProxy.Integration.Tests; + +public sealed class MinimalPermissionsUtilsTests : IDisposable +{ + private const string Variable = "DEVPROXY_TEST_SPEC_TENANT"; + + public MinimalPermissionsUtilsTests() + { + Environment.SetEnvironmentVariable(Variable, "a \"quoted\" value"); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(Variable, null); + } + + [Fact] + public void ResolveApiSpecVariables_Yaml_ResolvesValuesOnly() + { + var yaml = $$""" + info: + title: "${{{Variable}}}" + ${{{Variable}}}: key + """; + var logger = new WarningLogger(); + + var result = MinimalPermissionsUtils.ResolveApiSpecVariables(yaml, "spec.yaml", [], logger); + + var stream = new YamlStream(); + stream.Load(new StringReader(result)); + var root = (YamlMappingNode)stream.Documents[0].RootNode; + Assert.Equal("a \"quoted\" value", ((YamlMappingNode)root["info"])["title"].ToString()); + Assert.Equal("key", root["${" + Variable + "}"].ToString()); + Assert.Empty(logger.Warnings); + } + + [Theory] + [InlineData("spec.json", """{ "a": "$${TENANT_ID}", "b": "{TENANT_ID}" }""")] + [InlineData("spec.yaml", "a: $${TENANT_ID}\nb: '{TENANT_ID}'")] + public void ResolveApiSpecVariables_LegacyVariablesDontChangeEscapedPlaceholders(string fileName, string contents) + { + var logger = new WarningLogger(); + + var result = MinimalPermissionsUtils.ResolveApiSpecVariables(contents, fileName, new() { ["TENANT_ID"] = "contoso" }, logger); + + var (a, b) = fileName.EndsWith(".json", StringComparison.Ordinal) ? ReadJson(result) : ReadYaml(result); + Assert.Equal("${TENANT_ID}", a); + Assert.Equal("contoso", b); + _ = Assert.Single(logger.Warnings, w => w.Contains("deprecated", StringComparison.Ordinal)); + } + + [Fact] + public void ResolveApiSpecVariables_EscapedPlaceholderOnly_NoDeprecationWarning() + { + var logger = new WarningLogger(); + + var result = MinimalPermissionsUtils.ResolveApiSpecVariables("""{ "a": "$${TENANT_ID}" }""", "spec.json", new() { ["TENANT_ID"] = "contoso" }, logger); + + using var doc = JsonDocument.Parse(result); + Assert.Equal("${TENANT_ID}", doc.RootElement.GetProperty("a").GetString()); + Assert.Empty(logger.Warnings); + } + + private static (string? A, string? B) ReadJson(string json) + { + using var doc = JsonDocument.Parse(json); + return (doc.RootElement.GetProperty("a").GetString(), doc.RootElement.GetProperty("b").GetString()); + } + + private static (string? A, string? B) ReadYaml(string yaml) + { + var stream = new YamlStream(); + stream.Load(new StringReader(yaml)); + var root = (YamlMappingNode)stream.Documents[0].RootNode; + return (root["a"].ToString(), root["b"].ToString()); + } + + private sealed class WarningLogger : ILogger + { + public List Warnings { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (logLevel == LogLevel.Warning) + { + Warnings.Add(formatter(state, exception)); + } + } + } +} \ No newline at end of file diff --git a/DevProxy.Plugins/Utils/MinimalPermissionsUtils.cs b/DevProxy.Plugins/Utils/MinimalPermissionsUtils.cs index 898d8ec3c..a28908412 100644 --- a/DevProxy.Plugins/Utils/MinimalPermissionsUtils.cs +++ b/DevProxy.Plugins/Utils/MinimalPermissionsUtils.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging; using System.IdentityModel.Tokens.Jwt; using System.Text.Json; +using YamlDotNet.Core; namespace DevProxy.Plugins.Utils; @@ -18,30 +19,43 @@ static class MinimalPermissionsUtils public static string ResolveApiSpecVariables(string fileContents, string filePath, Dictionary legacyVariables, ILogger logger) { var unresolvedVariables = new List(); + var usesLegacyVariables = false; + + // legacy {NAME} variables are replaced only in text outside of ${NAME} placeholders + // so that escaped placeholders and resolved values aren't changed + string replaceLegacyVariables(string text) + { + var replaced = ProxyUtils.ReplaceVariables(text, legacyVariables, v => $"{{{v}}}"); + usesLegacyVariables |= !string.Equals(text, replaced, StringComparison.Ordinal); + return replaced; + } + Func? transformText = legacyVariables.Count > 0 ? replaceLegacyVariables : null; + string resolved; try { - // resolve JSON at the value level so that values with quotes don't break the document + // resolve values in the parsed document so that values with quotes don't break it + // and keys aren't changed resolved = Path.GetExtension(filePath).Equals(".json", StringComparison.OrdinalIgnoreCase) - ? EnvironmentVariablePlaceholders.ResolveInJson(fileContents, unresolvedVariables) - : EnvironmentVariablePlaceholders.Resolve(fileContents, unresolvedVariables); + ? EnvironmentVariablePlaceholders.ResolveInJson(fileContents, Environment.GetEnvironmentVariable, unresolvedVariables, transformText) + : EnvironmentVariablePlaceholders.ResolveInYaml(fileContents, Environment.GetEnvironmentVariable, unresolvedVariables, transformText); } - catch (JsonException) + catch (Exception ex) when (ex is JsonException or YamlException) { unresolvedVariables.Clear(); - resolved = EnvironmentVariablePlaceholders.Resolve(fileContents, unresolvedVariables); + usesLegacyVariables = false; + resolved = EnvironmentVariablePlaceholders.Resolve(fileContents, Environment.GetEnvironmentVariable, unresolvedVariables, transformText); } EnvironmentVariablePlaceholders.LogUnresolvedVariables(logger, unresolvedVariables, filePath); - var withLegacyVariables = ProxyUtils.ReplaceVariables(resolved, legacyVariables, v => $"{{{v}}}"); - if (!string.Equals(resolved, withLegacyVariables, StringComparison.Ordinal)) + if (usesLegacyVariables) { logger.LogWarning( "Referencing variables using {{NAME}} in {File} is deprecated and will be removed in a future version. Use ${{NAME}} instead", filePath); } - return withLegacyVariables; + return resolved; } /// From ce7e644a23b1c3547eb9e384c473cafb06ab0037 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 11:06:33 +0100 Subject: [PATCH 3/7] Skip inline rewrite rules and keep unquoted legacy YAML variables - Exclude RewritePlugin inline rewrite rules from load-time placeholder resolution, matching rewrites files - Replace legacy {NAME} variables in YAML API specs in raw text (outside ${NAME} placeholders) so unquoted values keep working Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ConfigurationPlaceholderTests.cs | 23 +++++++++++++++++++ ...ironmentVariableConfigurationExtensions.cs | 23 ++++++++++++++++++- .../Utils/EnvironmentVariablePlaceholders.cs | 22 ++++++++++++++++++ .../MinimalPermissionsUtilsTests.cs | 21 +++++++++++++++++ .../Utils/MinimalPermissionsUtils.cs | 16 ++++++++++--- 5 files changed, 101 insertions(+), 4 deletions(-) diff --git a/DevProxy.Abstractions.Tests/Extensions/ConfigurationPlaceholderTests.cs b/DevProxy.Abstractions.Tests/Extensions/ConfigurationPlaceholderTests.cs index a334f8c93..5c34d093e 100644 --- a/DevProxy.Abstractions.Tests/Extensions/ConfigurationPlaceholderTests.cs +++ b/DevProxy.Abstractions.Tests/Extensions/ConfigurationPlaceholderTests.cs @@ -78,6 +78,29 @@ public void AddConfigFile_CliVariablesDifferingInCasing_LastValueWins() Assert.Equal(OperatingSystem.IsWindows() ? "second" : "first", config["value"]); } + [Fact] + public void AddConfigFile_SkipsInlineRewriteRules() + { + var path = Write("devproxyrc.json", $$""" + { + "plugins": [ + { "name": "RewritePlugin", "enabled": true, "pluginPath": "plugins/DevProxy.Plugins.dll", "configSection": "myRewrites" } + ], + "myRewrites": { + "rewritesFile": "${{{OsVariable}}}.json", + "rewrites": [ { "out": { "url": "/users/${{{OsVariable}}}" } } ] + }, + "other": { "rewrites": [ "${{{OsVariable}}}" ] } + } + """); + + var (config, _) = Build(path, null); + + Assert.Equal("from-os.json", config["myRewrites:rewritesFile"]); + Assert.Equal($"/users/${{{OsVariable}}}", config["myRewrites:rewrites:0:out:url"]); + Assert.Equal("from-os", config["other:rewrites:0"]); + } + [Fact] public void AddConfigFile_Yaml_ResolvesPlaceholders() { diff --git a/DevProxy.Abstractions/Extensions/EnvironmentVariableConfigurationExtensions.cs b/DevProxy.Abstractions/Extensions/EnvironmentVariableConfigurationExtensions.cs index 9d1416cff..0e2a67356 100644 --- a/DevProxy.Abstractions/Extensions/EnvironmentVariableConfigurationExtensions.cs +++ b/DevProxy.Abstractions/Extensions/EnvironmentVariableConfigurationExtensions.cs @@ -131,9 +131,11 @@ public static IReadOnlyCollection Resolve(IDictionary d return envSection.TryGetValue(name, out value) ? value : Environment.GetEnvironmentVariable(name); } + var rewriteRulePrefixes = GetRewriteRulePrefixes(data); foreach (var key in data.Keys.ToArray()) { - if (IsEnvSectionVariable(key)) + if (IsEnvSectionVariable(key) || + rewriteRulePrefixes.Any(p => key.StartsWith(p, StringComparison.OrdinalIgnoreCase))) { continue; } @@ -148,6 +150,25 @@ public static IReadOnlyCollection Resolve(IDictionary d return unresolved; } + // RewritePlugin rules use ${name} for regex named groups and resolve ${env:NAME} + // per request, so inline rules are excluded from load-time resolution + private static string[] GetRewriteRulePrefixes(IDictionary data) + { + const string pluginsPrefix = "plugins:"; + const string nameSuffix = ":name"; + const string rewritePluginName = "RewritePlugin"; + + return [.. data + .Where(e => + e.Key.StartsWith(pluginsPrefix, StringComparison.OrdinalIgnoreCase) && + e.Key.EndsWith(nameSuffix, StringComparison.OrdinalIgnoreCase) && + e.Key.Count(c => c == ':') == 2 && + string.Equals(e.Value, rewritePluginName, StringComparison.Ordinal)) + .Select(e => data.TryGetValue($"{e.Key[..^nameSuffix.Length]}:configSection", out var section) ? section : null) + .Where(section => !string.IsNullOrEmpty(section)) + .Select(section => $"{section}:rewrites:")]; + } + private static bool IsEnvSectionVariable(string key) => key.StartsWith(EnvSectionPrefix, StringComparison.OrdinalIgnoreCase) && key.IndexOf(ConfigurationPath.KeyDelimiter, EnvSectionPrefix.Length, StringComparison.Ordinal) < 0; diff --git a/DevProxy.Abstractions/Utils/EnvironmentVariablePlaceholders.cs b/DevProxy.Abstractions/Utils/EnvironmentVariablePlaceholders.cs index f1f0b26ce..c0b127059 100644 --- a/DevProxy.Abstractions/Utils/EnvironmentVariablePlaceholders.cs +++ b/DevProxy.Abstractions/Utils/EnvironmentVariablePlaceholders.cs @@ -71,6 +71,28 @@ public static string Resolve(string value, Func lookup, ICollec return PlaceholderRegex().Replace(value, match => ResolveMatch(match, lookup, unresolvedVariables)); } + /// + /// Applies a transformation to the text outside placeholders, leaving placeholders + /// (including escaped ones) unchanged. + /// + public static string TransformOutsidePlaceholders(string value, Func transformText) + { + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(transformText); + + var result = new StringBuilder(); + var position = 0; + foreach (Match match in PlaceholderRegex().Matches(value)) + { + _ = result + .Append(transformText(value[position..match.Index])) + .Append(match.Value); + position = match.Index + match.Length; + } + _ = result.Append(transformText(value[position..])); + return result.ToString(); + } + private static string ResolveWithTextTransform(string value, Func lookup, ICollection? unresolvedVariables, Func transformText) { var result = new StringBuilder(); diff --git a/DevProxy.Integration.Tests/MinimalPermissionsUtilsTests.cs b/DevProxy.Integration.Tests/MinimalPermissionsUtilsTests.cs index 92c9850c6..aa1854cdb 100644 --- a/DevProxy.Integration.Tests/MinimalPermissionsUtilsTests.cs +++ b/DevProxy.Integration.Tests/MinimalPermissionsUtilsTests.cs @@ -59,6 +59,27 @@ public void ResolveApiSpecVariables_LegacyVariablesDontChangeEscapedPlaceholders _ = Assert.Single(logger.Warnings, w => w.Contains("deprecated", StringComparison.Ordinal)); } + [Fact] + public void ResolveApiSpecVariables_Yaml_ResolvesUnquotedLegacyVariables() + { + var yaml = """ + servers: + - url: {BASE_URL} + escaped: $${BASE_URL} + """; + var logger = new WarningLogger(); + + var result = MinimalPermissionsUtils.ResolveApiSpecVariables(yaml, "spec.yaml", new() { ["BASE_URL"] = "https://api.contoso.com" }, logger); + + var stream = new YamlStream(); + stream.Load(new StringReader(result)); + var root = (YamlMappingNode)stream.Documents[0].RootNode; + var server = (YamlMappingNode)((YamlSequenceNode)root["servers"])[0]; + Assert.Equal("https://api.contoso.com", Assert.IsType(server["url"]).Value); + Assert.Equal("${BASE_URL}", root["escaped"].ToString()); + _ = Assert.Single(logger.Warnings, w => w.Contains("deprecated", StringComparison.Ordinal)); + } + [Fact] public void ResolveApiSpecVariables_EscapedPlaceholderOnly_NoDeprecationWarning() { diff --git a/DevProxy.Plugins/Utils/MinimalPermissionsUtils.cs b/DevProxy.Plugins/Utils/MinimalPermissionsUtils.cs index a28908412..0c2a0cfdd 100644 --- a/DevProxy.Plugins/Utils/MinimalPermissionsUtils.cs +++ b/DevProxy.Plugins/Utils/MinimalPermissionsUtils.cs @@ -36,9 +36,19 @@ string replaceLegacyVariables(string text) { // resolve values in the parsed document so that values with quotes don't break it // and keys aren't changed - resolved = Path.GetExtension(filePath).Equals(".json", StringComparison.OrdinalIgnoreCase) - ? EnvironmentVariablePlaceholders.ResolveInJson(fileContents, Environment.GetEnvironmentVariable, unresolvedVariables, transformText) - : EnvironmentVariablePlaceholders.ResolveInYaml(fileContents, Environment.GetEnvironmentVariable, unresolvedVariables, transformText); + if (Path.GetExtension(filePath).Equals(".json", StringComparison.OrdinalIgnoreCase)) + { + resolved = EnvironmentVariablePlaceholders.ResolveInJson(fileContents, Environment.GetEnvironmentVariable, unresolvedVariables, transformText); + } + else + { + // unquoted legacy values like `url: {NAME}` parse as YAML flow mappings, + // so replace legacy variables in the raw text first, like before + var withLegacyVariables = transformText is null + ? fileContents + : EnvironmentVariablePlaceholders.TransformOutsidePlaceholders(fileContents, transformText); + resolved = EnvironmentVariablePlaceholders.ResolveInYaml(withLegacyVariables, Environment.GetEnvironmentVariable, unresolvedVariables); + } } catch (Exception ex) when (ex is JsonException or YamlException) { From 86e6ecfbb245c97c2de1216acafa3255ca8338c4 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 11:18:08 +0100 Subject: [PATCH 4/7] Resolve plugin metadata before excluding inline rewrite rules Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ConfigurationPlaceholderTests.cs | 18 ++++++++++++++++++ ...vironmentVariableConfigurationExtensions.cs | 15 +++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/DevProxy.Abstractions.Tests/Extensions/ConfigurationPlaceholderTests.cs b/DevProxy.Abstractions.Tests/Extensions/ConfigurationPlaceholderTests.cs index 5c34d093e..5adccfe6e 100644 --- a/DevProxy.Abstractions.Tests/Extensions/ConfigurationPlaceholderTests.cs +++ b/DevProxy.Abstractions.Tests/Extensions/ConfigurationPlaceholderTests.cs @@ -101,6 +101,24 @@ public void AddConfigFile_SkipsInlineRewriteRules() Assert.Equal("from-os", config["other:rewrites:0"]); } + [Fact] + public void AddConfigFile_SkipsInlineRewriteRules_WithPlaceholdersInPluginMetadata() + { + var path = Write("devproxyrc.json", $$""" + { + "env": { "PLUGIN": "RewritePlugin", "SECTION": "myRewrites" }, + "plugins": [ { "name": "${PLUGIN}", "configSection": "${SECTION}" } ], + "myRewrites": { "rewrites": [ { "out": { "url": "/users/${{{OsVariable}}}" } } ] } + } + """); + + var (config, _) = Build(path, null); + + Assert.Equal("RewritePlugin", config["plugins:0:name"]); + Assert.Equal("myRewrites", config["plugins:0:configSection"]); + Assert.Equal($"/users/${{{OsVariable}}}", config["myRewrites:rewrites:0:out:url"]); + } + [Fact] public void AddConfigFile_Yaml_ResolvesPlaceholders() { diff --git a/DevProxy.Abstractions/Extensions/EnvironmentVariableConfigurationExtensions.cs b/DevProxy.Abstractions/Extensions/EnvironmentVariableConfigurationExtensions.cs index 0e2a67356..f7632418b 100644 --- a/DevProxy.Abstractions/Extensions/EnvironmentVariableConfigurationExtensions.cs +++ b/DevProxy.Abstractions/Extensions/EnvironmentVariableConfigurationExtensions.cs @@ -131,7 +131,7 @@ public static IReadOnlyCollection Resolve(IDictionary d return envSection.TryGetValue(name, out value) ? value : Environment.GetEnvironmentVariable(name); } - var rewriteRulePrefixes = GetRewriteRulePrefixes(data); + var rewriteRulePrefixes = GetRewriteRulePrefixes(data, lookup); foreach (var key in data.Keys.ToArray()) { if (IsEnvSectionVariable(key) || @@ -152,8 +152,15 @@ public static IReadOnlyCollection Resolve(IDictionary d // RewritePlugin rules use ${name} for regex named groups and resolve ${env:NAME} // per request, so inline rules are excluded from load-time resolution - private static string[] GetRewriteRulePrefixes(IDictionary data) + private static string[] GetRewriteRulePrefixes(IDictionary data, Func lookup) { + // plugin names and sections may use placeholders too; resolve them here without + // changing data, which is resolved afterwards + string? resolve(string? value) + { + return value is null ? null : EnvironmentVariablePlaceholders.Resolve(value, lookup); + } + const string pluginsPrefix = "plugins:"; const string nameSuffix = ":name"; const string rewritePluginName = "RewritePlugin"; @@ -163,8 +170,8 @@ private static string[] GetRewriteRulePrefixes(IDictionary data e.Key.StartsWith(pluginsPrefix, StringComparison.OrdinalIgnoreCase) && e.Key.EndsWith(nameSuffix, StringComparison.OrdinalIgnoreCase) && e.Key.Count(c => c == ':') == 2 && - string.Equals(e.Value, rewritePluginName, StringComparison.Ordinal)) - .Select(e => data.TryGetValue($"{e.Key[..^nameSuffix.Length]}:configSection", out var section) ? section : null) + string.Equals(resolve(e.Value), rewritePluginName, StringComparison.Ordinal)) + .Select(e => data.TryGetValue($"{e.Key[..^nameSuffix.Length]}:configSection", out var section) ? resolve(section) : null) .Where(section => !string.IsNullOrEmpty(section)) .Select(section => $"{section}:rewrites:")]; } From 596a10290a0fc6a51c20ebbaf344cc378d1833de Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 14:17:52 +0100 Subject: [PATCH 5/7] Accept any variable name characters in ${env:NAME} placeholders ${env:NAME} now matches any characters except } so names like ProgramFiles(x86) resolve. Bare ${NAME} stays identifier-only. Pin YAML alias-as-key behavior with a test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../EnvironmentVariablePlaceholdersTests.cs | 65 +++++++++++++++++++ .../Utils/EnvironmentVariablePlaceholders.cs | 4 +- skills/dev-proxy/references/configuration.md | 4 +- 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/DevProxy.Abstractions.Tests/Utils/EnvironmentVariablePlaceholdersTests.cs b/DevProxy.Abstractions.Tests/Utils/EnvironmentVariablePlaceholdersTests.cs index 9f9d5be91..08323a298 100644 --- a/DevProxy.Abstractions.Tests/Utils/EnvironmentVariablePlaceholdersTests.cs +++ b/DevProxy.Abstractions.Tests/Utils/EnvironmentVariablePlaceholdersTests.cs @@ -143,4 +143,69 @@ public void Resolve_TransformsOnlyTextOutsidePlaceholders() Assert.Equal("legacy ${TENANT_ID} {TENANT_ID} ${MISSING}", result); } + + [Theory] + [InlineData("${env:ProgramFiles(x86)}", @"C:\Program Files (x86)")] + [InlineData("${env:FOO-BAR}", "dash")] + [InlineData("${env:FOO.BAR}", "dot")] + [InlineData("$${env:ProgramFiles(x86)}", "${env:ProgramFiles(x86)}")] + [InlineData("${ProgramFiles(x86)}", "${ProgramFiles(x86)}")] + [InlineData("${FOO-BAR}", "${FOO-BAR}")] + public void Resolve_EnvPrefixAcceptsAnyNameCharactersExceptBrace(string input, string expected) + { + var variables = new Dictionary + { + ["ProgramFiles(x86)"] = @"C:\Program Files (x86)", + ["FOO-BAR"] = "dash", + ["FOO.BAR"] = "dot" + }; + var unresolved = new List(); + + var result = EnvironmentVariablePlaceholders.Resolve(input, n => variables.TryGetValue(n, out var v) ? v : null, unresolved); + + Assert.Equal(expected, result); + Assert.Empty(unresolved); + } + + [Fact] + public void Resolve_ReportsUnsetEnvPrefixedNameWithSpecialCharacters() + { + var unresolved = new List(); + + var result = EnvironmentVariablePlaceholders.Resolve("${env:MISSING(x86)}", Lookup, unresolved); + + Assert.Equal("${env:MISSING(x86)}", result); + Assert.Equal(["MISSING(x86)"], unresolved); + } + + [Fact] + public void Resolve_TransformsTextAroundEnvPrefixedNamesWithSpecialCharacters() + { + var result = EnvironmentVariablePlaceholders.Resolve( + "{TENANT_ID} ${env:FOO-BAR} $${env:FOO-BAR}", + name => name == "FOO-BAR" ? "{TENANT_ID}" : null, + null, + text => text.Replace("{TENANT_ID}", "legacy", StringComparison.Ordinal)); + + Assert.Equal("legacy {TENANT_ID} ${env:FOO-BAR}", result); + } + + [Fact] + public void ResolveInYaml_AliasUsedAsKey_IsResolvedWithItsAnchor() + { + // intentional: aliases share the anchored node, so a key that aliases a + // resolved value changes with it + var yaml = """ + base: &id ${TENANT_ID} + *id : value + """; + + var result = EnvironmentVariablePlaceholders.ResolveInYaml(yaml, Lookup); + + var stream = new YamlDotNet.RepresentationModel.YamlStream(); + stream.Load(new StringReader(result)); + var root = (YamlDotNet.RepresentationModel.YamlMappingNode)stream.Documents[0].RootNode; + Assert.Equal("contoso", root["base"].ToString()); + Assert.Equal("value", root["contoso"].ToString()); + } } \ No newline at end of file diff --git a/DevProxy.Abstractions/Utils/EnvironmentVariablePlaceholders.cs b/DevProxy.Abstractions/Utils/EnvironmentVariablePlaceholders.cs index c0b127059..b5881d073 100644 --- a/DevProxy.Abstractions/Utils/EnvironmentVariablePlaceholders.cs +++ b/DevProxy.Abstractions/Utils/EnvironmentVariablePlaceholders.cs @@ -307,6 +307,8 @@ private static JsonNode ResolveInJsonNode(JsonNode node, Func re } } - [GeneratedRegex(@"(?\$)?\$\{(?:env:)?(?[A-Za-z_][A-Za-z0-9_]*)\}")] + // ${env:NAME} accepts any characters except } so that names like ProgramFiles(x86) work; + // bare ${NAME} is limited to identifiers to avoid matching unrelated ${...} text + [GeneratedRegex(@"(?\$)?\$\{(?:env:(?[^}]+)|(?[A-Za-z_][A-Za-z0-9_]*))\}")] private static partial Regex PlaceholderRegex(); } diff --git a/skills/dev-proxy/references/configuration.md b/skills/dev-proxy/references/configuration.md index e20f26005..bc4b76d94 100644 --- a/skills/dev-proxy/references/configuration.md +++ b/skills/dev-proxy/references/configuration.md @@ -48,7 +48,7 @@ A configuration file follows a specific property order: `$schema`, then `plugins ## Environment Variable Placeholders -Reference environment variables in config files and plugin data files (mocks, CRUD API definitions and data, error responses, OpenAPI specs used by MinimalPermissions plugins, and so on) with `${VAR_NAME}`. `${env:VAR_NAME}` works too. Dev Proxy resolves placeholders when it loads the file. +Reference environment variables in config files and plugin data files (mocks, CRUD API definitions and data, error responses, OpenAPI specs used by MinimalPermissions plugins, and so on) with `${VAR_NAME}`. `${env:VAR_NAME}` works too and also accepts names with characters like `(`, `-`, or `.`, for example `${env:ProgramFiles(x86)}`. Dev Proxy resolves placeholders when it loads the file. ```json { @@ -65,7 +65,7 @@ Reference environment variables in config files and plugin data files (mocks, CR - Use `$${VAR_NAME}` for a literal `${VAR_NAME}`. - Placeholders resolve in string values only (not property names). Config values like ports can use a string placeholder (`"port": "${PORT}"`); data files need the value's expected type. - `${...}` placeholders resolve once at load; `@`-tokens such as `@dynamic` and `@request.body.*` resolve per request. -- Exception: RewritePlugin rewrites files don't resolve placeholders at load because `${name}` in `out.url` refers to regex capture groups. Use `${env:VAR_NAME}`, which RewritePlugin resolves per request. +- Exception: RewritePlugin rewrite rules (in rewrites files or inline in the plugin config section) don't resolve placeholders at load because `${name}` in `out.url` refers to regex capture groups. Use `${env:VAR_NAME}`, which RewritePlugin resolves per request. - Deprecated: `@VAR_NAME` in Azure API Center plugin settings and `{VAR_NAME}` in OpenAPI specs used by MinimalPermissions plugins still work but log a warning. Use `${VAR_NAME}` instead. ## Plugin Architecture From c6e03d6ef55b6eec728b40bf521221c11863c05e Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 14:23:56 +0100 Subject: [PATCH 6/7] Restore original line endings Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ConfigurationPlaceholderTests.cs | 312 +++--- .../EnvironmentVariablePlaceholdersTests.cs | 420 ++++---- ...ironmentVariableConfigurationExtensions.cs | 364 +++---- DevProxy.Abstractions/Plugins/BaseLoader.cs | 370 +++---- .../Utils/EnvironmentVariablePlaceholders.cs | 628 ++++++------ .../MinimalPermissionsUtilsTests.cs | 248 ++--- DevProxy.Plugins/ApiCenter/ApiCenterClient.cs | 406 ++++---- .../Manipulation/RewritesLoader.cs | 94 +- .../MinimalPermissionsGuidancePlugin.cs | 660 ++++++------ .../Reporting/MinimalPermissionsPlugin.cs | 554 +++++----- .../Utils/MinimalPermissionsUtils.cs | 276 ++--- DevProxy.Tests/BaseLoaderPlaceholderTests.cs | 144 +-- .../ConfigurationManagerExtensions.cs | 58 +- DevProxy/Program.cs | 956 +++++++++--------- 14 files changed, 2745 insertions(+), 2745 deletions(-) diff --git a/DevProxy.Abstractions.Tests/Extensions/ConfigurationPlaceholderTests.cs b/DevProxy.Abstractions.Tests/Extensions/ConfigurationPlaceholderTests.cs index 5adccfe6e..4e5d7ec78 100644 --- a/DevProxy.Abstractions.Tests/Extensions/ConfigurationPlaceholderTests.cs +++ b/DevProxy.Abstractions.Tests/Extensions/ConfigurationPlaceholderTests.cs @@ -1,156 +1,156 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using Microsoft.Extensions.Configuration; -using Xunit; - -namespace DevProxy.Abstractions.Tests.Extensions; - -public sealed class ConfigurationPlaceholderTests : IDisposable -{ - private const string OsVariable = "DEVPROXY_TEST_PLACEHOLDER_OS"; - private readonly string _folder = Directory.CreateTempSubdirectory("devproxy-placeholders").FullName; - - public ConfigurationPlaceholderTests() - { - Environment.SetEnvironmentVariable(OsVariable, "from-os"); - } - - public void Dispose() - { - Environment.SetEnvironmentVariable(OsVariable, null); - Directory.Delete(_folder, true); - } - - [Fact] - public void AddConfigFile_Json_ResolvesPlaceholders() - { - var path = Write("devproxyrc.json", $$""" - { - "env": { "FROM_SECTION": "section-${{{OsVariable}}}" }, - "plugin": { - "os": "${{{OsVariable}}}", - "alias": "${env:{{OsVariable}}}", - "section": "${FROM_SECTION}", - "cli": "${FROM_CLI}", - "escaped": "$${{{OsVariable}}}", - "missing": "${DEVPROXY_TEST_PLACEHOLDER_MISSING}", - "port": "${PORT}" - } - } - """); - - var (config, unresolved) = Build(path, new Dictionary { ["FROM_CLI"] = "cli", ["PORT"] = "8000" }); - - Assert.Equal("from-os", config["plugin:os"]); - Assert.Equal("from-os", config["plugin:alias"]); - Assert.Equal("section-from-os", config["env:FROM_SECTION"]); - Assert.Equal("section-from-os", config["plugin:section"]); - Assert.Equal("cli", config["plugin:cli"]); - Assert.Equal($"${{{OsVariable}}}", config["plugin:escaped"]); - Assert.Equal("${DEVPROXY_TEST_PLACEHOLDER_MISSING}", config["plugin:missing"]); - Assert.Equal(8000, config.GetValue("plugin:port")); - Assert.Equal(["DEVPROXY_TEST_PLACEHOLDER_MISSING"], unresolved); - } - - [Fact] - public void AddConfigFile_CliVariablesOverrideEnvSection() - { - var path = Write("devproxyrc.json", """ - { "env": { "NAME": "section" }, "value": "${NAME}" } - """); - - var (config, _) = Build(path, new Dictionary { ["NAME"] = "cli" }); - - Assert.Equal("cli", config["value"]); - } - - [Fact] - public void AddConfigFile_CliVariablesDifferingInCasing_LastValueWins() - { - var path = Write("devproxyrc.json", """ - { "value": "${NAME}" } - """); - - var (config, _) = Build(path, new Dictionary { ["NAME"] = "first", ["name"] = "second" }); - - Assert.Equal(OperatingSystem.IsWindows() ? "second" : "first", config["value"]); - } - - [Fact] - public void AddConfigFile_SkipsInlineRewriteRules() - { - var path = Write("devproxyrc.json", $$""" - { - "plugins": [ - { "name": "RewritePlugin", "enabled": true, "pluginPath": "plugins/DevProxy.Plugins.dll", "configSection": "myRewrites" } - ], - "myRewrites": { - "rewritesFile": "${{{OsVariable}}}.json", - "rewrites": [ { "out": { "url": "/users/${{{OsVariable}}}" } } ] - }, - "other": { "rewrites": [ "${{{OsVariable}}}" ] } - } - """); - - var (config, _) = Build(path, null); - - Assert.Equal("from-os.json", config["myRewrites:rewritesFile"]); - Assert.Equal($"/users/${{{OsVariable}}}", config["myRewrites:rewrites:0:out:url"]); - Assert.Equal("from-os", config["other:rewrites:0"]); - } - - [Fact] - public void AddConfigFile_SkipsInlineRewriteRules_WithPlaceholdersInPluginMetadata() - { - var path = Write("devproxyrc.json", $$""" - { - "env": { "PLUGIN": "RewritePlugin", "SECTION": "myRewrites" }, - "plugins": [ { "name": "${PLUGIN}", "configSection": "${SECTION}" } ], - "myRewrites": { "rewrites": [ { "out": { "url": "/users/${{{OsVariable}}}" } } ] } - } - """); - - var (config, _) = Build(path, null); - - Assert.Equal("RewritePlugin", config["plugins:0:name"]); - Assert.Equal("myRewrites", config["plugins:0:configSection"]); - Assert.Equal($"/users/${{{OsVariable}}}", config["myRewrites:rewrites:0:out:url"]); - } - - [Fact] - public void AddConfigFile_Yaml_ResolvesPlaceholders() - { - var path = Write("devproxyrc.yaml", $$""" - env: - FROM_SECTION: section - plugin: - os: ${{{OsVariable}}} - section: "${FROM_SECTION}" - list: - - ${env:{{OsVariable}}} - """); - - var (config, unresolved) = Build(path, null); - - Assert.Equal("from-os", config["plugin:os"]); - Assert.Equal("section", config["plugin:section"]); - Assert.Equal("from-os", config["plugin:list:0"]); - Assert.Empty(unresolved); - } - - private string Write(string name, string contents) - { - var path = Path.Combine(_folder, name); - File.WriteAllText(path, contents); - return path; - } - - private static (IConfigurationRoot Config, IReadOnlyCollection Unresolved) Build(string path, IReadOnlyDictionary? variables) - { - var config = new ConfigurationBuilder().AddConfigFile(path, environmentVariables: variables).Build(); - var provider = Assert.Single(config.Providers.OfType()); - return (config, provider.UnresolvedEnvironmentVariables); - } -} +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace DevProxy.Abstractions.Tests.Extensions; + +public sealed class ConfigurationPlaceholderTests : IDisposable +{ + private const string OsVariable = "DEVPROXY_TEST_PLACEHOLDER_OS"; + private readonly string _folder = Directory.CreateTempSubdirectory("devproxy-placeholders").FullName; + + public ConfigurationPlaceholderTests() + { + Environment.SetEnvironmentVariable(OsVariable, "from-os"); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(OsVariable, null); + Directory.Delete(_folder, true); + } + + [Fact] + public void AddConfigFile_Json_ResolvesPlaceholders() + { + var path = Write("devproxyrc.json", $$""" + { + "env": { "FROM_SECTION": "section-${{{OsVariable}}}" }, + "plugin": { + "os": "${{{OsVariable}}}", + "alias": "${env:{{OsVariable}}}", + "section": "${FROM_SECTION}", + "cli": "${FROM_CLI}", + "escaped": "$${{{OsVariable}}}", + "missing": "${DEVPROXY_TEST_PLACEHOLDER_MISSING}", + "port": "${PORT}" + } + } + """); + + var (config, unresolved) = Build(path, new Dictionary { ["FROM_CLI"] = "cli", ["PORT"] = "8000" }); + + Assert.Equal("from-os", config["plugin:os"]); + Assert.Equal("from-os", config["plugin:alias"]); + Assert.Equal("section-from-os", config["env:FROM_SECTION"]); + Assert.Equal("section-from-os", config["plugin:section"]); + Assert.Equal("cli", config["plugin:cli"]); + Assert.Equal($"${{{OsVariable}}}", config["plugin:escaped"]); + Assert.Equal("${DEVPROXY_TEST_PLACEHOLDER_MISSING}", config["plugin:missing"]); + Assert.Equal(8000, config.GetValue("plugin:port")); + Assert.Equal(["DEVPROXY_TEST_PLACEHOLDER_MISSING"], unresolved); + } + + [Fact] + public void AddConfigFile_CliVariablesOverrideEnvSection() + { + var path = Write("devproxyrc.json", """ + { "env": { "NAME": "section" }, "value": "${NAME}" } + """); + + var (config, _) = Build(path, new Dictionary { ["NAME"] = "cli" }); + + Assert.Equal("cli", config["value"]); + } + + [Fact] + public void AddConfigFile_CliVariablesDifferingInCasing_LastValueWins() + { + var path = Write("devproxyrc.json", """ + { "value": "${NAME}" } + """); + + var (config, _) = Build(path, new Dictionary { ["NAME"] = "first", ["name"] = "second" }); + + Assert.Equal(OperatingSystem.IsWindows() ? "second" : "first", config["value"]); + } + + [Fact] + public void AddConfigFile_SkipsInlineRewriteRules() + { + var path = Write("devproxyrc.json", $$""" + { + "plugins": [ + { "name": "RewritePlugin", "enabled": true, "pluginPath": "plugins/DevProxy.Plugins.dll", "configSection": "myRewrites" } + ], + "myRewrites": { + "rewritesFile": "${{{OsVariable}}}.json", + "rewrites": [ { "out": { "url": "/users/${{{OsVariable}}}" } } ] + }, + "other": { "rewrites": [ "${{{OsVariable}}}" ] } + } + """); + + var (config, _) = Build(path, null); + + Assert.Equal("from-os.json", config["myRewrites:rewritesFile"]); + Assert.Equal($"/users/${{{OsVariable}}}", config["myRewrites:rewrites:0:out:url"]); + Assert.Equal("from-os", config["other:rewrites:0"]); + } + + [Fact] + public void AddConfigFile_SkipsInlineRewriteRules_WithPlaceholdersInPluginMetadata() + { + var path = Write("devproxyrc.json", $$""" + { + "env": { "PLUGIN": "RewritePlugin", "SECTION": "myRewrites" }, + "plugins": [ { "name": "${PLUGIN}", "configSection": "${SECTION}" } ], + "myRewrites": { "rewrites": [ { "out": { "url": "/users/${{{OsVariable}}}" } } ] } + } + """); + + var (config, _) = Build(path, null); + + Assert.Equal("RewritePlugin", config["plugins:0:name"]); + Assert.Equal("myRewrites", config["plugins:0:configSection"]); + Assert.Equal($"/users/${{{OsVariable}}}", config["myRewrites:rewrites:0:out:url"]); + } + + [Fact] + public void AddConfigFile_Yaml_ResolvesPlaceholders() + { + var path = Write("devproxyrc.yaml", $$""" + env: + FROM_SECTION: section + plugin: + os: ${{{OsVariable}}} + section: "${FROM_SECTION}" + list: + - ${env:{{OsVariable}}} + """); + + var (config, unresolved) = Build(path, null); + + Assert.Equal("from-os", config["plugin:os"]); + Assert.Equal("section", config["plugin:section"]); + Assert.Equal("from-os", config["plugin:list:0"]); + Assert.Empty(unresolved); + } + + private string Write(string name, string contents) + { + var path = Path.Combine(_folder, name); + File.WriteAllText(path, contents); + return path; + } + + private static (IConfigurationRoot Config, IReadOnlyCollection Unresolved) Build(string path, IReadOnlyDictionary? variables) + { + var config = new ConfigurationBuilder().AddConfigFile(path, environmentVariables: variables).Build(); + var provider = Assert.Single(config.Providers.OfType()); + return (config, provider.UnresolvedEnvironmentVariables); + } +} diff --git a/DevProxy.Abstractions.Tests/Utils/EnvironmentVariablePlaceholdersTests.cs b/DevProxy.Abstractions.Tests/Utils/EnvironmentVariablePlaceholdersTests.cs index 08323a298..c82ef6d99 100644 --- a/DevProxy.Abstractions.Tests/Utils/EnvironmentVariablePlaceholdersTests.cs +++ b/DevProxy.Abstractions.Tests/Utils/EnvironmentVariablePlaceholdersTests.cs @@ -1,211 +1,211 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using DevProxy.Abstractions.Utils; -using System.Text.Json; -using Xunit; - -namespace DevProxy.Abstractions.Tests.Utils; - -public class EnvironmentVariablePlaceholdersTests -{ - private static readonly Dictionary Variables = new() - { - ["TENANT_ID"] = "contoso", - ["CLIENT_ID"] = "1234", - ["QUOTED"] = "a \"quoted\" \\ value" - }; - - private static string? Lookup(string name) => Variables.TryGetValue(name, out var value) ? value : null; - - [Theory] - [InlineData("${TENANT_ID}", "contoso")] - [InlineData("${env:TENANT_ID}", "contoso")] - [InlineData("https://login/${TENANT_ID}/v2.0", "https://login/contoso/v2.0")] - [InlineData("${TENANT_ID}${CLIENT_ID}", "contoso1234")] - [InlineData("$${TENANT_ID}", "${TENANT_ID}")] - [InlineData("$${env:TENANT_ID}", "${env:TENANT_ID}")] - [InlineData("no placeholders", "no placeholders")] - [InlineData("{TENANT_ID} @TENANT_ID", "{TENANT_ID} @TENANT_ID")] - [InlineData("${not a var}", "${not a var}")] - [InlineData("$TENANT_ID", "$TENANT_ID")] - public void Resolve_ReplacesPlaceholders(string input, string expected) => - Assert.Equal(expected, EnvironmentVariablePlaceholders.Resolve(input, Lookup)); - - [Fact] - public void Resolve_LeavesUnsetVariablesAndReportsThemOnce() - { - var unresolved = new List(); - - var result = EnvironmentVariablePlaceholders.Resolve("${MISSING}/${TENANT_ID}/${env:MISSING}", Lookup, unresolved); - - Assert.Equal("${MISSING}/contoso/${env:MISSING}", result); - Assert.Equal(["MISSING"], unresolved); - } - - [Fact] - public void Resolve_DoesNotReportEscapedPlaceholders() - { - var unresolved = new List(); - - _ = EnvironmentVariablePlaceholders.Resolve("$${MISSING}", Lookup, unresolved); - - Assert.Empty(unresolved); - } - - [Fact] - public void ResolveInJson_ResolvesStringValuesOnly() - { - var json = """ - { - // comment - "${TENANT_ID}": "${TENANT_ID}", - "nested": { "items": ["${CLIENT_ID}", 1, true, null] }, - "quoted": "${QUOTED}", - } - """; - - var result = EnvironmentVariablePlaceholders.ResolveInJson(json, Lookup); - - using var doc = JsonDocument.Parse(result); - var root = doc.RootElement; - Assert.Equal("contoso", root.GetProperty("${TENANT_ID}").GetString()); - Assert.Equal("1234", root.GetProperty("nested").GetProperty("items")[0].GetString()); - Assert.Equal(1, root.GetProperty("nested").GetProperty("items")[1].GetInt32()); - Assert.Equal("a \"quoted\" \\ value", root.GetProperty("quoted").GetString()); - } - - [Fact] - public void ResolveInJson_ReturnsOriginalWhenNoPlaceholders() - { - var json = "{ /* keep */ \"a\": \"b\" }"; - - Assert.Same(json, EnvironmentVariablePlaceholders.ResolveInJson(json, Lookup)); - } - - [Fact] - public void ResolveInJson_ResolvesUnicodeEscapedPlaceholders() - { - var json = """{ "audience": "\u0024{CLIENT_ID}" }"""; - - var result = EnvironmentVariablePlaceholders.ResolveInJson(json, Lookup); - - using var doc = JsonDocument.Parse(result); - Assert.Equal("1234", doc.RootElement.GetProperty("audience").GetString()); - } - - [Fact] - public void ResolveInJson_ReturnsOriginalWhenEscapesWithoutPlaceholders() - { - var json = """{ "a": "\u00e9" }"""; - - Assert.Same(json, EnvironmentVariablePlaceholders.ResolveInJson(json, Lookup)); - } - - [Fact] - public void ResolveInYaml_ResolvesScalarValuesOnly() - { - var yaml = """ - ${TENANT_ID}: "${TENANT_ID}" - quoted: "${QUOTED}" - base: &base - id: ${CLIENT_ID} - copy: *base - """; - - var result = EnvironmentVariablePlaceholders.ResolveInYaml(yaml, Lookup); - - var stream = new YamlDotNet.RepresentationModel.YamlStream(); - stream.Load(new StringReader(result)); - var root = (YamlDotNet.RepresentationModel.YamlMappingNode)stream.Documents[0].RootNode; - Assert.Equal("contoso", root["${TENANT_ID}"].ToString()); - Assert.Equal("a \"quoted\" \\ value", root["quoted"].ToString()); - Assert.Equal("1234", ((YamlDotNet.RepresentationModel.YamlMappingNode)root["copy"])["id"].ToString()); - } - - [Fact] - public void ResolveInYaml_ReturnsOriginalWhenNoPlaceholders() - { - var yaml = "a: b # keep"; - - Assert.Same(yaml, EnvironmentVariablePlaceholders.ResolveInYaml(yaml, Lookup)); - } - - [Fact] - public void Resolve_TransformsOnlyTextOutsidePlaceholders() - { - var result = EnvironmentVariablePlaceholders.Resolve( - "{TENANT_ID} $${TENANT_ID} ${CLIENT_ID} ${MISSING}", - name => name == "CLIENT_ID" ? "{TENANT_ID}" : Lookup(name), - null, - text => text.Replace("{TENANT_ID}", "legacy", StringComparison.Ordinal)); - - Assert.Equal("legacy ${TENANT_ID} {TENANT_ID} ${MISSING}", result); - } - - [Theory] - [InlineData("${env:ProgramFiles(x86)}", @"C:\Program Files (x86)")] - [InlineData("${env:FOO-BAR}", "dash")] - [InlineData("${env:FOO.BAR}", "dot")] - [InlineData("$${env:ProgramFiles(x86)}", "${env:ProgramFiles(x86)}")] - [InlineData("${ProgramFiles(x86)}", "${ProgramFiles(x86)}")] - [InlineData("${FOO-BAR}", "${FOO-BAR}")] - public void Resolve_EnvPrefixAcceptsAnyNameCharactersExceptBrace(string input, string expected) - { - var variables = new Dictionary - { - ["ProgramFiles(x86)"] = @"C:\Program Files (x86)", - ["FOO-BAR"] = "dash", - ["FOO.BAR"] = "dot" - }; - var unresolved = new List(); - - var result = EnvironmentVariablePlaceholders.Resolve(input, n => variables.TryGetValue(n, out var v) ? v : null, unresolved); - - Assert.Equal(expected, result); - Assert.Empty(unresolved); - } - - [Fact] - public void Resolve_ReportsUnsetEnvPrefixedNameWithSpecialCharacters() - { - var unresolved = new List(); - - var result = EnvironmentVariablePlaceholders.Resolve("${env:MISSING(x86)}", Lookup, unresolved); - - Assert.Equal("${env:MISSING(x86)}", result); - Assert.Equal(["MISSING(x86)"], unresolved); - } - - [Fact] - public void Resolve_TransformsTextAroundEnvPrefixedNamesWithSpecialCharacters() - { - var result = EnvironmentVariablePlaceholders.Resolve( - "{TENANT_ID} ${env:FOO-BAR} $${env:FOO-BAR}", - name => name == "FOO-BAR" ? "{TENANT_ID}" : null, - null, - text => text.Replace("{TENANT_ID}", "legacy", StringComparison.Ordinal)); - - Assert.Equal("legacy {TENANT_ID} ${env:FOO-BAR}", result); - } - - [Fact] - public void ResolveInYaml_AliasUsedAsKey_IsResolvedWithItsAnchor() - { - // intentional: aliases share the anchored node, so a key that aliases a - // resolved value changes with it - var yaml = """ - base: &id ${TENANT_ID} - *id : value - """; - - var result = EnvironmentVariablePlaceholders.ResolveInYaml(yaml, Lookup); - - var stream = new YamlDotNet.RepresentationModel.YamlStream(); - stream.Load(new StringReader(result)); - var root = (YamlDotNet.RepresentationModel.YamlMappingNode)stream.Documents[0].RootNode; - Assert.Equal("contoso", root["base"].ToString()); - Assert.Equal("value", root["contoso"].ToString()); - } +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Utils; +using System.Text.Json; +using Xunit; + +namespace DevProxy.Abstractions.Tests.Utils; + +public class EnvironmentVariablePlaceholdersTests +{ + private static readonly Dictionary Variables = new() + { + ["TENANT_ID"] = "contoso", + ["CLIENT_ID"] = "1234", + ["QUOTED"] = "a \"quoted\" \\ value" + }; + + private static string? Lookup(string name) => Variables.TryGetValue(name, out var value) ? value : null; + + [Theory] + [InlineData("${TENANT_ID}", "contoso")] + [InlineData("${env:TENANT_ID}", "contoso")] + [InlineData("https://login/${TENANT_ID}/v2.0", "https://login/contoso/v2.0")] + [InlineData("${TENANT_ID}${CLIENT_ID}", "contoso1234")] + [InlineData("$${TENANT_ID}", "${TENANT_ID}")] + [InlineData("$${env:TENANT_ID}", "${env:TENANT_ID}")] + [InlineData("no placeholders", "no placeholders")] + [InlineData("{TENANT_ID} @TENANT_ID", "{TENANT_ID} @TENANT_ID")] + [InlineData("${not a var}", "${not a var}")] + [InlineData("$TENANT_ID", "$TENANT_ID")] + public void Resolve_ReplacesPlaceholders(string input, string expected) => + Assert.Equal(expected, EnvironmentVariablePlaceholders.Resolve(input, Lookup)); + + [Fact] + public void Resolve_LeavesUnsetVariablesAndReportsThemOnce() + { + var unresolved = new List(); + + var result = EnvironmentVariablePlaceholders.Resolve("${MISSING}/${TENANT_ID}/${env:MISSING}", Lookup, unresolved); + + Assert.Equal("${MISSING}/contoso/${env:MISSING}", result); + Assert.Equal(["MISSING"], unresolved); + } + + [Fact] + public void Resolve_DoesNotReportEscapedPlaceholders() + { + var unresolved = new List(); + + _ = EnvironmentVariablePlaceholders.Resolve("$${MISSING}", Lookup, unresolved); + + Assert.Empty(unresolved); + } + + [Fact] + public void ResolveInJson_ResolvesStringValuesOnly() + { + var json = """ + { + // comment + "${TENANT_ID}": "${TENANT_ID}", + "nested": { "items": ["${CLIENT_ID}", 1, true, null] }, + "quoted": "${QUOTED}", + } + """; + + var result = EnvironmentVariablePlaceholders.ResolveInJson(json, Lookup); + + using var doc = JsonDocument.Parse(result); + var root = doc.RootElement; + Assert.Equal("contoso", root.GetProperty("${TENANT_ID}").GetString()); + Assert.Equal("1234", root.GetProperty("nested").GetProperty("items")[0].GetString()); + Assert.Equal(1, root.GetProperty("nested").GetProperty("items")[1].GetInt32()); + Assert.Equal("a \"quoted\" \\ value", root.GetProperty("quoted").GetString()); + } + + [Fact] + public void ResolveInJson_ReturnsOriginalWhenNoPlaceholders() + { + var json = "{ /* keep */ \"a\": \"b\" }"; + + Assert.Same(json, EnvironmentVariablePlaceholders.ResolveInJson(json, Lookup)); + } + + [Fact] + public void ResolveInJson_ResolvesUnicodeEscapedPlaceholders() + { + var json = """{ "audience": "\u0024{CLIENT_ID}" }"""; + + var result = EnvironmentVariablePlaceholders.ResolveInJson(json, Lookup); + + using var doc = JsonDocument.Parse(result); + Assert.Equal("1234", doc.RootElement.GetProperty("audience").GetString()); + } + + [Fact] + public void ResolveInJson_ReturnsOriginalWhenEscapesWithoutPlaceholders() + { + var json = """{ "a": "\u00e9" }"""; + + Assert.Same(json, EnvironmentVariablePlaceholders.ResolveInJson(json, Lookup)); + } + + [Fact] + public void ResolveInYaml_ResolvesScalarValuesOnly() + { + var yaml = """ + ${TENANT_ID}: "${TENANT_ID}" + quoted: "${QUOTED}" + base: &base + id: ${CLIENT_ID} + copy: *base + """; + + var result = EnvironmentVariablePlaceholders.ResolveInYaml(yaml, Lookup); + + var stream = new YamlDotNet.RepresentationModel.YamlStream(); + stream.Load(new StringReader(result)); + var root = (YamlDotNet.RepresentationModel.YamlMappingNode)stream.Documents[0].RootNode; + Assert.Equal("contoso", root["${TENANT_ID}"].ToString()); + Assert.Equal("a \"quoted\" \\ value", root["quoted"].ToString()); + Assert.Equal("1234", ((YamlDotNet.RepresentationModel.YamlMappingNode)root["copy"])["id"].ToString()); + } + + [Fact] + public void ResolveInYaml_ReturnsOriginalWhenNoPlaceholders() + { + var yaml = "a: b # keep"; + + Assert.Same(yaml, EnvironmentVariablePlaceholders.ResolveInYaml(yaml, Lookup)); + } + + [Fact] + public void Resolve_TransformsOnlyTextOutsidePlaceholders() + { + var result = EnvironmentVariablePlaceholders.Resolve( + "{TENANT_ID} $${TENANT_ID} ${CLIENT_ID} ${MISSING}", + name => name == "CLIENT_ID" ? "{TENANT_ID}" : Lookup(name), + null, + text => text.Replace("{TENANT_ID}", "legacy", StringComparison.Ordinal)); + + Assert.Equal("legacy ${TENANT_ID} {TENANT_ID} ${MISSING}", result); + } + + [Theory] + [InlineData("${env:ProgramFiles(x86)}", @"C:\Program Files (x86)")] + [InlineData("${env:FOO-BAR}", "dash")] + [InlineData("${env:FOO.BAR}", "dot")] + [InlineData("$${env:ProgramFiles(x86)}", "${env:ProgramFiles(x86)}")] + [InlineData("${ProgramFiles(x86)}", "${ProgramFiles(x86)}")] + [InlineData("${FOO-BAR}", "${FOO-BAR}")] + public void Resolve_EnvPrefixAcceptsAnyNameCharactersExceptBrace(string input, string expected) + { + var variables = new Dictionary + { + ["ProgramFiles(x86)"] = @"C:\Program Files (x86)", + ["FOO-BAR"] = "dash", + ["FOO.BAR"] = "dot" + }; + var unresolved = new List(); + + var result = EnvironmentVariablePlaceholders.Resolve(input, n => variables.TryGetValue(n, out var v) ? v : null, unresolved); + + Assert.Equal(expected, result); + Assert.Empty(unresolved); + } + + [Fact] + public void Resolve_ReportsUnsetEnvPrefixedNameWithSpecialCharacters() + { + var unresolved = new List(); + + var result = EnvironmentVariablePlaceholders.Resolve("${env:MISSING(x86)}", Lookup, unresolved); + + Assert.Equal("${env:MISSING(x86)}", result); + Assert.Equal(["MISSING(x86)"], unresolved); + } + + [Fact] + public void Resolve_TransformsTextAroundEnvPrefixedNamesWithSpecialCharacters() + { + var result = EnvironmentVariablePlaceholders.Resolve( + "{TENANT_ID} ${env:FOO-BAR} $${env:FOO-BAR}", + name => name == "FOO-BAR" ? "{TENANT_ID}" : null, + null, + text => text.Replace("{TENANT_ID}", "legacy", StringComparison.Ordinal)); + + Assert.Equal("legacy {TENANT_ID} ${env:FOO-BAR}", result); + } + + [Fact] + public void ResolveInYaml_AliasUsedAsKey_IsResolvedWithItsAnchor() + { + // intentional: aliases share the anchored node, so a key that aliases a + // resolved value changes with it + var yaml = """ + base: &id ${TENANT_ID} + *id : value + """; + + var result = EnvironmentVariablePlaceholders.ResolveInYaml(yaml, Lookup); + + var stream = new YamlDotNet.RepresentationModel.YamlStream(); + stream.Load(new StringReader(result)); + var root = (YamlDotNet.RepresentationModel.YamlMappingNode)stream.Documents[0].RootNode; + Assert.Equal("contoso", root["base"].ToString()); + Assert.Equal("value", root["contoso"].ToString()); + } } \ No newline at end of file diff --git a/DevProxy.Abstractions/Extensions/EnvironmentVariableConfigurationExtensions.cs b/DevProxy.Abstractions/Extensions/EnvironmentVariableConfigurationExtensions.cs index f7632418b..5f0327f20 100644 --- a/DevProxy.Abstractions/Extensions/EnvironmentVariableConfigurationExtensions.cs +++ b/DevProxy.Abstractions/Extensions/EnvironmentVariableConfigurationExtensions.cs @@ -1,182 +1,182 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using DevProxy.Abstractions.Utils; -using Microsoft.Extensions.Configuration.Json; - -#pragma warning disable IDE0130 -namespace Microsoft.Extensions.Configuration; -#pragma warning restore IDE0130 - -/// -/// A configuration provider that resolves environment variable placeholders -/// (${NAME}, ${env:NAME}) in configuration values. -/// -public interface IEnvironmentVariablePlaceholderProvider -{ - /// - /// Path of the configuration file. - /// - string? Path { get; } - - /// - /// Names of environment variables referenced in the configuration file that couldn't be resolved during the last load. - /// - IReadOnlyCollection UnresolvedEnvironmentVariables { get; } -} - -/// -/// JSON configuration source that resolves environment variable placeholders. -/// -public sealed class EnvironmentVariableJsonConfigurationSource : JsonConfigurationSource -{ - /// - /// Variables that take precedence over the env configuration section and process environment variables. - /// - public IReadOnlyDictionary? EnvironmentVariables { get; set; } - - /// - public override IConfigurationProvider Build(IConfigurationBuilder builder) - { - EnsureDefaults(builder); - return new EnvironmentVariableJsonConfigurationProvider(this); - } -} - -/// -/// JSON configuration provider that resolves environment variable placeholders. -/// -public sealed class EnvironmentVariableJsonConfigurationProvider(EnvironmentVariableJsonConfigurationSource source) : - JsonConfigurationProvider(source), IEnvironmentVariablePlaceholderProvider -{ - /// - public string? Path => source.Path; - - /// - public IReadOnlyCollection UnresolvedEnvironmentVariables { get; private set; } = []; - - /// - public override void Load(Stream stream) - { - base.Load(stream); - UnresolvedEnvironmentVariables = ConfigurationPlaceholderResolver.Resolve(Data, source.EnvironmentVariables); - } -} - -/// -/// Resolves environment variable placeholders in flattened configuration data. -/// -public static class ConfigurationPlaceholderResolver -{ - private const string EnvSectionPrefix = "env:"; - - private static StringComparer VariableNameComparer => - OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; - - /// - /// Resolves placeholders in configuration values in place. Variables are looked up in - /// first, then in the env configuration - /// section, and finally in process environment variables. Values in the env - /// section itself are resolved against and - /// process environment variables only. - /// - /// Names of variables that couldn't be resolved. - public static IReadOnlyCollection Resolve(IDictionary data, IReadOnlyDictionary? environmentVariables) - { - ArgumentNullException.ThrowIfNull(data); - - var unresolved = new List(); - Dictionary? overrides = null; - if (environmentVariables is not null) - { - overrides = new(VariableNameComparer); - // use the indexer so that names differing only in casing on Windows don't throw; last value wins - foreach (var (name, value) in environmentVariables) - { - overrides[name] = value; - } - } - - string? baseLookup(string name) - { - return overrides is not null && overrides.TryGetValue(name, out var value) - ? value - : Environment.GetEnvironmentVariable(name); - } - - var envSection = new Dictionary(VariableNameComparer); - foreach (var key in data.Keys.ToArray()) - { - if (!IsEnvSectionVariable(key)) - { - continue; - } - - var value = data[key]; - if (value is not null) - { - value = EnvironmentVariablePlaceholders.Resolve(value, baseLookup, unresolved); - data[key] = value; - envSection[key[EnvSectionPrefix.Length..]] = value; - } - } - - string? lookup(string name) - { - if (overrides is not null && overrides.TryGetValue(name, out var value)) - { - return value; - } - return envSection.TryGetValue(name, out value) ? value : Environment.GetEnvironmentVariable(name); - } - - var rewriteRulePrefixes = GetRewriteRulePrefixes(data, lookup); - foreach (var key in data.Keys.ToArray()) - { - if (IsEnvSectionVariable(key) || - rewriteRulePrefixes.Any(p => key.StartsWith(p, StringComparison.OrdinalIgnoreCase))) - { - continue; - } - - var value = data[key]; - if (value is not null) - { - data[key] = EnvironmentVariablePlaceholders.Resolve(value, lookup, unresolved); - } - } - - return unresolved; - } - - // RewritePlugin rules use ${name} for regex named groups and resolve ${env:NAME} - // per request, so inline rules are excluded from load-time resolution - private static string[] GetRewriteRulePrefixes(IDictionary data, Func lookup) - { - // plugin names and sections may use placeholders too; resolve them here without - // changing data, which is resolved afterwards - string? resolve(string? value) - { - return value is null ? null : EnvironmentVariablePlaceholders.Resolve(value, lookup); - } - - const string pluginsPrefix = "plugins:"; - const string nameSuffix = ":name"; - const string rewritePluginName = "RewritePlugin"; - - return [.. data - .Where(e => - e.Key.StartsWith(pluginsPrefix, StringComparison.OrdinalIgnoreCase) && - e.Key.EndsWith(nameSuffix, StringComparison.OrdinalIgnoreCase) && - e.Key.Count(c => c == ':') == 2 && - string.Equals(resolve(e.Value), rewritePluginName, StringComparison.Ordinal)) - .Select(e => data.TryGetValue($"{e.Key[..^nameSuffix.Length]}:configSection", out var section) ? resolve(section) : null) - .Where(section => !string.IsNullOrEmpty(section)) - .Select(section => $"{section}:rewrites:")]; - } - - private static bool IsEnvSectionVariable(string key) => - key.StartsWith(EnvSectionPrefix, StringComparison.OrdinalIgnoreCase) && - key.IndexOf(ConfigurationPath.KeyDelimiter, EnvSectionPrefix.Length, StringComparison.Ordinal) < 0; -} +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Utils; +using Microsoft.Extensions.Configuration.Json; + +#pragma warning disable IDE0130 +namespace Microsoft.Extensions.Configuration; +#pragma warning restore IDE0130 + +/// +/// A configuration provider that resolves environment variable placeholders +/// (${NAME}, ${env:NAME}) in configuration values. +/// +public interface IEnvironmentVariablePlaceholderProvider +{ + /// + /// Path of the configuration file. + /// + string? Path { get; } + + /// + /// Names of environment variables referenced in the configuration file that couldn't be resolved during the last load. + /// + IReadOnlyCollection UnresolvedEnvironmentVariables { get; } +} + +/// +/// JSON configuration source that resolves environment variable placeholders. +/// +public sealed class EnvironmentVariableJsonConfigurationSource : JsonConfigurationSource +{ + /// + /// Variables that take precedence over the env configuration section and process environment variables. + /// + public IReadOnlyDictionary? EnvironmentVariables { get; set; } + + /// + public override IConfigurationProvider Build(IConfigurationBuilder builder) + { + EnsureDefaults(builder); + return new EnvironmentVariableJsonConfigurationProvider(this); + } +} + +/// +/// JSON configuration provider that resolves environment variable placeholders. +/// +public sealed class EnvironmentVariableJsonConfigurationProvider(EnvironmentVariableJsonConfigurationSource source) : + JsonConfigurationProvider(source), IEnvironmentVariablePlaceholderProvider +{ + /// + public string? Path => source.Path; + + /// + public IReadOnlyCollection UnresolvedEnvironmentVariables { get; private set; } = []; + + /// + public override void Load(Stream stream) + { + base.Load(stream); + UnresolvedEnvironmentVariables = ConfigurationPlaceholderResolver.Resolve(Data, source.EnvironmentVariables); + } +} + +/// +/// Resolves environment variable placeholders in flattened configuration data. +/// +public static class ConfigurationPlaceholderResolver +{ + private const string EnvSectionPrefix = "env:"; + + private static StringComparer VariableNameComparer => + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + + /// + /// Resolves placeholders in configuration values in place. Variables are looked up in + /// first, then in the env configuration + /// section, and finally in process environment variables. Values in the env + /// section itself are resolved against and + /// process environment variables only. + /// + /// Names of variables that couldn't be resolved. + public static IReadOnlyCollection Resolve(IDictionary data, IReadOnlyDictionary? environmentVariables) + { + ArgumentNullException.ThrowIfNull(data); + + var unresolved = new List(); + Dictionary? overrides = null; + if (environmentVariables is not null) + { + overrides = new(VariableNameComparer); + // use the indexer so that names differing only in casing on Windows don't throw; last value wins + foreach (var (name, value) in environmentVariables) + { + overrides[name] = value; + } + } + + string? baseLookup(string name) + { + return overrides is not null && overrides.TryGetValue(name, out var value) + ? value + : Environment.GetEnvironmentVariable(name); + } + + var envSection = new Dictionary(VariableNameComparer); + foreach (var key in data.Keys.ToArray()) + { + if (!IsEnvSectionVariable(key)) + { + continue; + } + + var value = data[key]; + if (value is not null) + { + value = EnvironmentVariablePlaceholders.Resolve(value, baseLookup, unresolved); + data[key] = value; + envSection[key[EnvSectionPrefix.Length..]] = value; + } + } + + string? lookup(string name) + { + if (overrides is not null && overrides.TryGetValue(name, out var value)) + { + return value; + } + return envSection.TryGetValue(name, out value) ? value : Environment.GetEnvironmentVariable(name); + } + + var rewriteRulePrefixes = GetRewriteRulePrefixes(data, lookup); + foreach (var key in data.Keys.ToArray()) + { + if (IsEnvSectionVariable(key) || + rewriteRulePrefixes.Any(p => key.StartsWith(p, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + var value = data[key]; + if (value is not null) + { + data[key] = EnvironmentVariablePlaceholders.Resolve(value, lookup, unresolved); + } + } + + return unresolved; + } + + // RewritePlugin rules use ${name} for regex named groups and resolve ${env:NAME} + // per request, so inline rules are excluded from load-time resolution + private static string[] GetRewriteRulePrefixes(IDictionary data, Func lookup) + { + // plugin names and sections may use placeholders too; resolve them here without + // changing data, which is resolved afterwards + string? resolve(string? value) + { + return value is null ? null : EnvironmentVariablePlaceholders.Resolve(value, lookup); + } + + const string pluginsPrefix = "plugins:"; + const string nameSuffix = ":name"; + const string rewritePluginName = "RewritePlugin"; + + return [.. data + .Where(e => + e.Key.StartsWith(pluginsPrefix, StringComparison.OrdinalIgnoreCase) && + e.Key.EndsWith(nameSuffix, StringComparison.OrdinalIgnoreCase) && + e.Key.Count(c => c == ':') == 2 && + string.Equals(resolve(e.Value), rewritePluginName, StringComparison.Ordinal)) + .Select(e => data.TryGetValue($"{e.Key[..^nameSuffix.Length]}:configSection", out var section) ? resolve(section) : null) + .Where(section => !string.IsNullOrEmpty(section)) + .Select(section => $"{section}:rewrites:")]; + } + + private static bool IsEnvSectionVariable(string key) => + key.StartsWith(EnvSectionPrefix, StringComparison.OrdinalIgnoreCase) && + key.IndexOf(ConfigurationPath.KeyDelimiter, EnvSectionPrefix.Length, StringComparison.Ordinal) < 0; +} diff --git a/DevProxy.Abstractions/Plugins/BaseLoader.cs b/DevProxy.Abstractions/Plugins/BaseLoader.cs index b9d210ebe..8bd505f99 100644 --- a/DevProxy.Abstractions/Plugins/BaseLoader.cs +++ b/DevProxy.Abstractions/Plugins/BaseLoader.cs @@ -1,186 +1,186 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using DevProxy.Abstractions.Proxy; -using DevProxy.Abstractions.Utils; -using Microsoft.Extensions.Logging; -using System.Text.Json; - -namespace DevProxy.Abstractions.Plugins; - -public abstract class BaseLoader(HttpClient httpClient, ILogger logger, IProxyConfiguration proxyConfiguration) : IDisposable -{ - private CancellationToken? _cancellationToken; -#pragma warning disable CA2213 // HttpClient is injected, so we don't dispose it - private readonly HttpClient _httpClient = httpClient; -#pragma warning restore CA2213 - private readonly bool _validateSchemas = proxyConfiguration.ValidateSchemas; - private readonly Lock _debounceLock = new(); - private readonly int _debounceDelay = 300; // milliseconds - - private FileSystemWatcher? _watcher; - private Timer? _debounceTimer; - private volatile bool _isDisposed; - - protected abstract string FilePath { get; } - /// - /// Whether to resolve environment variable placeholders (${NAME}, ${env:NAME}) - /// in string values of the loaded file. Defaults to true. - /// - protected virtual bool ResolveEnvironmentVariables => true; - protected ILogger Logger { get; } = logger; - - public async Task InitFileWatcherAsync(CancellationToken cancellationToken) - { - _cancellationToken = cancellationToken; - - if (_watcher is not null) - { - return; - } - - var path = Path.GetDirectoryName(FilePath) ?? throw new InvalidOperationException($"{FilePath} is an invalid path"); - if (!File.Exists(FilePath)) - { - Logger.LogWarning("File {File} not found. No data will be provided", FilePath); - return; - } - - _watcher = new(Path.GetFullPath(path)) - { - NotifyFilter = NotifyFilters.CreationTime - | NotifyFilters.FileName - | NotifyFilters.LastWrite - | NotifyFilters.Size, - Filter = Path.GetFileName(FilePath) - }; - _watcher.Changed += File_Changed; - _watcher.EnableRaisingEvents = true; - - await LoadFileContentsAsync(cancellationToken); - } - - protected abstract void LoadData(string fileContents); - - protected virtual void Dispose(bool disposing) - { - if (_isDisposed) - { - return; - } - - // Set flag first to prevent in-flight timer callbacks from proceeding - _isDisposed = true; - - if (disposing) - { - _watcher?.Dispose(); - _debounceTimer?.Dispose(); - } - } - - private async Task ValidateFileContentsAsync(string fileContents, CancellationToken cancellationToken) - { - using var document = JsonDocument.Parse(fileContents, ProxyUtils.JsonDocumentOptions); - var root = document.RootElement; - - // Schema validation only applies to JSON objects, not arrays - if (root.ValueKind != JsonValueKind.Object || - !root.TryGetProperty("$schema", out var schemaUrlElement)) - { - Logger.LogDebug("Schema reference not found in file {File}. Skipping schema validation", FilePath); - return true; - } - - var schemaUrl = schemaUrlElement.GetString() ?? ""; - ProxyUtils.ValidateSchemaVersion(schemaUrl, Logger); - var (IsValid, ValidationErrors) = await ProxyUtils.ValidateJsonAsync(fileContents, schemaUrl, _httpClient, Logger, cancellationToken); - - if (!IsValid) - { - if (Logger.IsEnabled(LogLevel.Error)) - { - Logger.LogError("Schema validation failed for {File} with the following errors: {Errors}", FilePath, string.Join(", ", ValidationErrors)); - } - } - - return IsValid; - } - - private async Task LoadFileContentsAsync(CancellationToken cancellationToken) - { - if (_isDisposed) - { - return; - } - - if (!File.Exists(FilePath)) - { - Logger.LogWarning("File {File} not found. No data will be loaded", FilePath); - return; - } - - try - { - using var stream = new FileStream(FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - using var reader = new StreamReader(stream); - var fileContents = await reader.ReadToEndAsync(cancellationToken); - - // Convert YAML to JSON if needed - string jsonContents; - if (ProxyYaml.IsYamlFile(FilePath)) - { - if (!ProxyYaml.TryConvertYamlToJson(fileContents, out var converted, out var error)) - { - Logger.LogError("Failed to parse YAML file {File}: {Error}", FilePath, error); - return; - } - jsonContents = converted!; - } - else - { - jsonContents = fileContents; - } - - if (ResolveEnvironmentVariables) - { - var unresolvedVariables = new List(); - jsonContents = EnvironmentVariablePlaceholders.ResolveInJson(jsonContents, unresolvedVariables); - EnvironmentVariablePlaceholders.LogUnresolvedVariables(Logger, unresolvedVariables, FilePath); - } - - if (!_validateSchemas || await ValidateFileContentsAsync(jsonContents, cancellationToken)) - { - LoadData(jsonContents); - } - } - catch (Exception ex) - { - Logger.LogError(ex, "An error has occurred while reading {File}:", FilePath); - } - } - - private void File_Changed(object sender, FileSystemEventArgs e) - { - // Don't process file changes if this loader has been disposed - if (_isDisposed) - { - return; - } - - lock (_debounceLock) - { - _debounceTimer?.Dispose(); -#pragma warning disable CS4014 // we don't need to await this - _debounceTimer = new(_ => LoadFileContentsAsync(_cancellationToken ?? CancellationToken.None), null, _debounceDelay, Timeout.Infinite); -#pragma warning restore CS4014 - } - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Utils; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace DevProxy.Abstractions.Plugins; + +public abstract class BaseLoader(HttpClient httpClient, ILogger logger, IProxyConfiguration proxyConfiguration) : IDisposable +{ + private CancellationToken? _cancellationToken; +#pragma warning disable CA2213 // HttpClient is injected, so we don't dispose it + private readonly HttpClient _httpClient = httpClient; +#pragma warning restore CA2213 + private readonly bool _validateSchemas = proxyConfiguration.ValidateSchemas; + private readonly Lock _debounceLock = new(); + private readonly int _debounceDelay = 300; // milliseconds + + private FileSystemWatcher? _watcher; + private Timer? _debounceTimer; + private volatile bool _isDisposed; + + protected abstract string FilePath { get; } + /// + /// Whether to resolve environment variable placeholders (${NAME}, ${env:NAME}) + /// in string values of the loaded file. Defaults to true. + /// + protected virtual bool ResolveEnvironmentVariables => true; + protected ILogger Logger { get; } = logger; + + public async Task InitFileWatcherAsync(CancellationToken cancellationToken) + { + _cancellationToken = cancellationToken; + + if (_watcher is not null) + { + return; + } + + var path = Path.GetDirectoryName(FilePath) ?? throw new InvalidOperationException($"{FilePath} is an invalid path"); + if (!File.Exists(FilePath)) + { + Logger.LogWarning("File {File} not found. No data will be provided", FilePath); + return; + } + + _watcher = new(Path.GetFullPath(path)) + { + NotifyFilter = NotifyFilters.CreationTime + | NotifyFilters.FileName + | NotifyFilters.LastWrite + | NotifyFilters.Size, + Filter = Path.GetFileName(FilePath) + }; + _watcher.Changed += File_Changed; + _watcher.EnableRaisingEvents = true; + + await LoadFileContentsAsync(cancellationToken); + } + + protected abstract void LoadData(string fileContents); + + protected virtual void Dispose(bool disposing) + { + if (_isDisposed) + { + return; + } + + // Set flag first to prevent in-flight timer callbacks from proceeding + _isDisposed = true; + + if (disposing) + { + _watcher?.Dispose(); + _debounceTimer?.Dispose(); + } + } + + private async Task ValidateFileContentsAsync(string fileContents, CancellationToken cancellationToken) + { + using var document = JsonDocument.Parse(fileContents, ProxyUtils.JsonDocumentOptions); + var root = document.RootElement; + + // Schema validation only applies to JSON objects, not arrays + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("$schema", out var schemaUrlElement)) + { + Logger.LogDebug("Schema reference not found in file {File}. Skipping schema validation", FilePath); + return true; + } + + var schemaUrl = schemaUrlElement.GetString() ?? ""; + ProxyUtils.ValidateSchemaVersion(schemaUrl, Logger); + var (IsValid, ValidationErrors) = await ProxyUtils.ValidateJsonAsync(fileContents, schemaUrl, _httpClient, Logger, cancellationToken); + + if (!IsValid) + { + if (Logger.IsEnabled(LogLevel.Error)) + { + Logger.LogError("Schema validation failed for {File} with the following errors: {Errors}", FilePath, string.Join(", ", ValidationErrors)); + } + } + + return IsValid; + } + + private async Task LoadFileContentsAsync(CancellationToken cancellationToken) + { + if (_isDisposed) + { + return; + } + + if (!File.Exists(FilePath)) + { + Logger.LogWarning("File {File} not found. No data will be loaded", FilePath); + return; + } + + try + { + using var stream = new FileStream(FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream); + var fileContents = await reader.ReadToEndAsync(cancellationToken); + + // Convert YAML to JSON if needed + string jsonContents; + if (ProxyYaml.IsYamlFile(FilePath)) + { + if (!ProxyYaml.TryConvertYamlToJson(fileContents, out var converted, out var error)) + { + Logger.LogError("Failed to parse YAML file {File}: {Error}", FilePath, error); + return; + } + jsonContents = converted!; + } + else + { + jsonContents = fileContents; + } + + if (ResolveEnvironmentVariables) + { + var unresolvedVariables = new List(); + jsonContents = EnvironmentVariablePlaceholders.ResolveInJson(jsonContents, unresolvedVariables); + EnvironmentVariablePlaceholders.LogUnresolvedVariables(Logger, unresolvedVariables, FilePath); + } + + if (!_validateSchemas || await ValidateFileContentsAsync(jsonContents, cancellationToken)) + { + LoadData(jsonContents); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "An error has occurred while reading {File}:", FilePath); + } + } + + private void File_Changed(object sender, FileSystemEventArgs e) + { + // Don't process file changes if this loader has been disposed + if (_isDisposed) + { + return; + } + + lock (_debounceLock) + { + _debounceTimer?.Dispose(); +#pragma warning disable CS4014 // we don't need to await this + _debounceTimer = new(_ => LoadFileContentsAsync(_cancellationToken ?? CancellationToken.None), null, _debounceDelay, Timeout.Infinite); +#pragma warning restore CS4014 + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } } \ No newline at end of file diff --git a/DevProxy.Abstractions/Utils/EnvironmentVariablePlaceholders.cs b/DevProxy.Abstractions/Utils/EnvironmentVariablePlaceholders.cs index b5881d073..a6bbe76d4 100644 --- a/DevProxy.Abstractions/Utils/EnvironmentVariablePlaceholders.cs +++ b/DevProxy.Abstractions/Utils/EnvironmentVariablePlaceholders.cs @@ -1,314 +1,314 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using Microsoft.Extensions.Logging; -using System.Text; -using System.Text.Encodings.Web; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; -using YamlDotNet.RepresentationModel; - -namespace DevProxy.Abstractions.Utils; - -/// -/// Resolves environment variable placeholders in user-authored files. -/// Supports ${NAME} and ${env:NAME}. Use $${NAME} to -/// output a literal ${NAME}. Placeholders that reference variables -/// that aren't set are left as-is. -/// -public static partial class EnvironmentVariablePlaceholders -{ - private static readonly JsonSerializerOptions _writeOptions = new() - { - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }; - - /// - /// Returns true if the value contains something that looks like a placeholder or an escaped placeholder. - /// - public static bool ContainsPlaceholder(string? value) => - !string.IsNullOrEmpty(value) && PlaceholderRegex().IsMatch(value); - - /// - /// Resolves placeholders in a string value using process environment variables. - /// - public static string Resolve(string value, ICollection? unresolvedVariables = null) => - Resolve(value, Environment.GetEnvironmentVariable, unresolvedVariables); - - /// - /// Resolves placeholders in a string value using the specified lookup. - /// - /// Value to resolve. - /// Function that returns the value of a variable or null if it isn't set. - /// Collects names of variables that couldn't be resolved. - public static string Resolve(string value, Func lookup, ICollection? unresolvedVariables = null) => - Resolve(value, lookup, unresolvedVariables, null); - - /// - /// Resolves placeholders in a string value using the specified lookup. - /// - /// Value to resolve. - /// Function that returns the value of a variable or null if it isn't set. - /// Collects names of variables that couldn't be resolved. - /// Optional transformation applied only to the text outside placeholders. - public static string Resolve(string value, Func lookup, ICollection? unresolvedVariables, Func? transformText) - { - ArgumentNullException.ThrowIfNull(value); - ArgumentNullException.ThrowIfNull(lookup); - - if (transformText is not null) - { - return ResolveWithTextTransform(value, lookup, unresolvedVariables, transformText); - } - - if (!value.Contains("${", StringComparison.Ordinal)) - { - return value; - } - - return PlaceholderRegex().Replace(value, match => ResolveMatch(match, lookup, unresolvedVariables)); - } - - /// - /// Applies a transformation to the text outside placeholders, leaving placeholders - /// (including escaped ones) unchanged. - /// - public static string TransformOutsidePlaceholders(string value, Func transformText) - { - ArgumentNullException.ThrowIfNull(value); - ArgumentNullException.ThrowIfNull(transformText); - - var result = new StringBuilder(); - var position = 0; - foreach (Match match in PlaceholderRegex().Matches(value)) - { - _ = result - .Append(transformText(value[position..match.Index])) - .Append(match.Value); - position = match.Index + match.Length; - } - _ = result.Append(transformText(value[position..])); - return result.ToString(); - } - - private static string ResolveWithTextTransform(string value, Func lookup, ICollection? unresolvedVariables, Func transformText) - { - var result = new StringBuilder(); - var position = 0; - foreach (Match match in PlaceholderRegex().Matches(value)) - { - _ = result - .Append(transformText(value[position..match.Index])) - .Append(ResolveMatch(match, lookup, unresolvedVariables)); - position = match.Index + match.Length; - } - _ = result.Append(transformText(value[position..])); - return result.ToString(); - } - - private static string ResolveMatch(Match match, Func lookup, ICollection? unresolvedVariables) - { - var name = match.Groups["name"].Value; - if (match.Groups["escape"].Success) - { - // $${NAME} -> literal ${NAME} - return match.Value[1..]; - } - - var variableValue = lookup(name); - if (variableValue is null) - { - if (unresolvedVariables is not null && !unresolvedVariables.Contains(name)) - { - unresolvedVariables.Add(name); - } - return match.Value; - } - - return variableValue; - } - - /// - /// Resolves placeholders in all string values of a JSON document. Property names aren't changed. - /// Returns the original string if it doesn't contain any placeholders. - /// - public static string ResolveInJson(string json, Func lookup, ICollection? unresolvedVariables = null) => - ResolveInJson(json, lookup, unresolvedVariables, null); - - /// - /// Resolves placeholders in all string values of a JSON document. Property names aren't changed. - /// Returns the original string if no values changed. - /// - /// JSON document. - /// Function that returns the value of a variable or null if it isn't set. - /// Collects names of variables that couldn't be resolved. - /// Optional transformation applied only to the text outside placeholders. - public static string ResolveInJson(string json, Func lookup, ICollection? unresolvedVariables, Func? transformText) - { - ArgumentNullException.ThrowIfNull(json); - ArgumentNullException.ThrowIfNull(lookup); - - // string values may contain JSON escapes (eg. \u0024{NAME}) so only skip parsing when there are none - if (transformText is null && - !ContainsPlaceholder(json) && - !json.Contains("\\u", StringComparison.Ordinal)) - { - return json; - } - - var node = JsonNode.Parse(json, documentOptions: ProxyUtils.JsonDocumentOptions); - if (node is null) - { - return json; - } - - var changed = false; - node = ResolveInJsonNode(node, value => - { - var resolved = Resolve(value, lookup, unresolvedVariables, transformText); - changed |= !string.Equals(value, resolved, StringComparison.Ordinal); - return resolved; - }); - return changed ? node.ToJsonString(_writeOptions) : json; - } - - /// - /// Resolves placeholders in all string values of a JSON document using process environment variables. - /// - public static string ResolveInJson(string json, ICollection? unresolvedVariables = null) => - ResolveInJson(json, Environment.GetEnvironmentVariable, unresolvedVariables); - - /// - /// Resolves placeholders in all scalar values of a YAML document. Mapping keys aren't changed. - /// Returns the original string if no values changed. - /// - /// YAML document. - /// Function that returns the value of a variable or null if it isn't set. - /// Collects names of variables that couldn't be resolved. - /// Optional transformation applied only to the text outside placeholders. - public static string ResolveInYaml(string yaml, Func lookup, ICollection? unresolvedVariables = null, Func? transformText = null) - { - ArgumentNullException.ThrowIfNull(yaml); - ArgumentNullException.ThrowIfNull(lookup); - - var stream = new YamlStream(); - using (var reader = new StringReader(yaml)) - { - stream.Load(reader); - } - - var changed = false; - var visited = new HashSet(ReferenceEqualityComparer.Instance); - void resolveNode(YamlNode node) - { - // aliases point to the same node instance so each node is resolved once - if (!visited.Add(node)) - { - return; - } - - switch (node) - { - case YamlScalarNode scalar when scalar.Value is not null: - var resolved = Resolve(scalar.Value, lookup, unresolvedVariables, transformText); - if (!string.Equals(scalar.Value, resolved, StringComparison.Ordinal)) - { - scalar.Value = resolved; - changed = true; - } - break; - case YamlMappingNode mapping: - foreach (var child in mapping.Children) - { - resolveNode(child.Value); - } - break; - case YamlSequenceNode sequence: - foreach (var child in sequence.Children) - { - resolveNode(child); - } - break; - default: - break; - } - } - - foreach (var document in stream.Documents) - { - resolveNode(document.RootNode); - } - - if (!changed) - { - return yaml; - } - - using var writer = new StringWriter(); - stream.Save(writer, assignAnchors: false); - return writer.ToString(); - } - - /// - /// Logs a warning for each environment variable that couldn't be resolved. - /// - public static void LogUnresolvedVariables(ILogger logger, IEnumerable unresolvedVariables, string source) - { - ArgumentNullException.ThrowIfNull(logger); - ArgumentNullException.ThrowIfNull(unresolvedVariables); - - foreach (var name in unresolvedVariables) - { - logger.LogWarning( - "Environment variable {Name} referenced in {Source} isn't set. Leaving the placeholder as-is", - name, source); - } - } - - private static JsonNode ResolveInJsonNode(JsonNode node, Func resolve) - { - switch (node) - { - case JsonObject obj: - foreach (var property in obj.ToArray()) - { - if (property.Value is not null) - { - var resolved = ResolveInJsonNode(property.Value, resolve); - if (!ReferenceEquals(resolved, property.Value)) - { - obj[property.Key] = resolved; - } - } - } - return obj; - case JsonArray array: - for (var i = 0; i < array.Count; i++) - { - var item = array[i]; - if (item is not null) - { - var resolved = ResolveInJsonNode(item, resolve); - if (!ReferenceEquals(resolved, item)) - { - array[i] = resolved; - } - } - } - return array; - case JsonValue value when value.GetValueKind() == JsonValueKind.String: - var s = value.GetValue(); - var r = resolve(s); - return string.Equals(s, r, StringComparison.Ordinal) ? value : JsonValue.Create(r); - default: - return node; - } - } - - // ${env:NAME} accepts any characters except } so that names like ProgramFiles(x86) work; - // bare ${NAME} is limited to identifiers to avoid matching unrelated ${...} text - [GeneratedRegex(@"(?\$)?\$\{(?:env:(?[^}]+)|(?[A-Za-z_][A-Za-z0-9_]*))\}")] - private static partial Regex PlaceholderRegex(); -} +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Extensions.Logging; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using YamlDotNet.RepresentationModel; + +namespace DevProxy.Abstractions.Utils; + +/// +/// Resolves environment variable placeholders in user-authored files. +/// Supports ${NAME} and ${env:NAME}. Use $${NAME} to +/// output a literal ${NAME}. Placeholders that reference variables +/// that aren't set are left as-is. +/// +public static partial class EnvironmentVariablePlaceholders +{ + private static readonly JsonSerializerOptions _writeOptions = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + /// + /// Returns true if the value contains something that looks like a placeholder or an escaped placeholder. + /// + public static bool ContainsPlaceholder(string? value) => + !string.IsNullOrEmpty(value) && PlaceholderRegex().IsMatch(value); + + /// + /// Resolves placeholders in a string value using process environment variables. + /// + public static string Resolve(string value, ICollection? unresolvedVariables = null) => + Resolve(value, Environment.GetEnvironmentVariable, unresolvedVariables); + + /// + /// Resolves placeholders in a string value using the specified lookup. + /// + /// Value to resolve. + /// Function that returns the value of a variable or null if it isn't set. + /// Collects names of variables that couldn't be resolved. + public static string Resolve(string value, Func lookup, ICollection? unresolvedVariables = null) => + Resolve(value, lookup, unresolvedVariables, null); + + /// + /// Resolves placeholders in a string value using the specified lookup. + /// + /// Value to resolve. + /// Function that returns the value of a variable or null if it isn't set. + /// Collects names of variables that couldn't be resolved. + /// Optional transformation applied only to the text outside placeholders. + public static string Resolve(string value, Func lookup, ICollection? unresolvedVariables, Func? transformText) + { + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(lookup); + + if (transformText is not null) + { + return ResolveWithTextTransform(value, lookup, unresolvedVariables, transformText); + } + + if (!value.Contains("${", StringComparison.Ordinal)) + { + return value; + } + + return PlaceholderRegex().Replace(value, match => ResolveMatch(match, lookup, unresolvedVariables)); + } + + /// + /// Applies a transformation to the text outside placeholders, leaving placeholders + /// (including escaped ones) unchanged. + /// + public static string TransformOutsidePlaceholders(string value, Func transformText) + { + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(transformText); + + var result = new StringBuilder(); + var position = 0; + foreach (Match match in PlaceholderRegex().Matches(value)) + { + _ = result + .Append(transformText(value[position..match.Index])) + .Append(match.Value); + position = match.Index + match.Length; + } + _ = result.Append(transformText(value[position..])); + return result.ToString(); + } + + private static string ResolveWithTextTransform(string value, Func lookup, ICollection? unresolvedVariables, Func transformText) + { + var result = new StringBuilder(); + var position = 0; + foreach (Match match in PlaceholderRegex().Matches(value)) + { + _ = result + .Append(transformText(value[position..match.Index])) + .Append(ResolveMatch(match, lookup, unresolvedVariables)); + position = match.Index + match.Length; + } + _ = result.Append(transformText(value[position..])); + return result.ToString(); + } + + private static string ResolveMatch(Match match, Func lookup, ICollection? unresolvedVariables) + { + var name = match.Groups["name"].Value; + if (match.Groups["escape"].Success) + { + // $${NAME} -> literal ${NAME} + return match.Value[1..]; + } + + var variableValue = lookup(name); + if (variableValue is null) + { + if (unresolvedVariables is not null && !unresolvedVariables.Contains(name)) + { + unresolvedVariables.Add(name); + } + return match.Value; + } + + return variableValue; + } + + /// + /// Resolves placeholders in all string values of a JSON document. Property names aren't changed. + /// Returns the original string if it doesn't contain any placeholders. + /// + public static string ResolveInJson(string json, Func lookup, ICollection? unresolvedVariables = null) => + ResolveInJson(json, lookup, unresolvedVariables, null); + + /// + /// Resolves placeholders in all string values of a JSON document. Property names aren't changed. + /// Returns the original string if no values changed. + /// + /// JSON document. + /// Function that returns the value of a variable or null if it isn't set. + /// Collects names of variables that couldn't be resolved. + /// Optional transformation applied only to the text outside placeholders. + public static string ResolveInJson(string json, Func lookup, ICollection? unresolvedVariables, Func? transformText) + { + ArgumentNullException.ThrowIfNull(json); + ArgumentNullException.ThrowIfNull(lookup); + + // string values may contain JSON escapes (eg. \u0024{NAME}) so only skip parsing when there are none + if (transformText is null && + !ContainsPlaceholder(json) && + !json.Contains("\\u", StringComparison.Ordinal)) + { + return json; + } + + var node = JsonNode.Parse(json, documentOptions: ProxyUtils.JsonDocumentOptions); + if (node is null) + { + return json; + } + + var changed = false; + node = ResolveInJsonNode(node, value => + { + var resolved = Resolve(value, lookup, unresolvedVariables, transformText); + changed |= !string.Equals(value, resolved, StringComparison.Ordinal); + return resolved; + }); + return changed ? node.ToJsonString(_writeOptions) : json; + } + + /// + /// Resolves placeholders in all string values of a JSON document using process environment variables. + /// + public static string ResolveInJson(string json, ICollection? unresolvedVariables = null) => + ResolveInJson(json, Environment.GetEnvironmentVariable, unresolvedVariables); + + /// + /// Resolves placeholders in all scalar values of a YAML document. Mapping keys aren't changed. + /// Returns the original string if no values changed. + /// + /// YAML document. + /// Function that returns the value of a variable or null if it isn't set. + /// Collects names of variables that couldn't be resolved. + /// Optional transformation applied only to the text outside placeholders. + public static string ResolveInYaml(string yaml, Func lookup, ICollection? unresolvedVariables = null, Func? transformText = null) + { + ArgumentNullException.ThrowIfNull(yaml); + ArgumentNullException.ThrowIfNull(lookup); + + var stream = new YamlStream(); + using (var reader = new StringReader(yaml)) + { + stream.Load(reader); + } + + var changed = false; + var visited = new HashSet(ReferenceEqualityComparer.Instance); + void resolveNode(YamlNode node) + { + // aliases point to the same node instance so each node is resolved once + if (!visited.Add(node)) + { + return; + } + + switch (node) + { + case YamlScalarNode scalar when scalar.Value is not null: + var resolved = Resolve(scalar.Value, lookup, unresolvedVariables, transformText); + if (!string.Equals(scalar.Value, resolved, StringComparison.Ordinal)) + { + scalar.Value = resolved; + changed = true; + } + break; + case YamlMappingNode mapping: + foreach (var child in mapping.Children) + { + resolveNode(child.Value); + } + break; + case YamlSequenceNode sequence: + foreach (var child in sequence.Children) + { + resolveNode(child); + } + break; + default: + break; + } + } + + foreach (var document in stream.Documents) + { + resolveNode(document.RootNode); + } + + if (!changed) + { + return yaml; + } + + using var writer = new StringWriter(); + stream.Save(writer, assignAnchors: false); + return writer.ToString(); + } + + /// + /// Logs a warning for each environment variable that couldn't be resolved. + /// + public static void LogUnresolvedVariables(ILogger logger, IEnumerable unresolvedVariables, string source) + { + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(unresolvedVariables); + + foreach (var name in unresolvedVariables) + { + logger.LogWarning( + "Environment variable {Name} referenced in {Source} isn't set. Leaving the placeholder as-is", + name, source); + } + } + + private static JsonNode ResolveInJsonNode(JsonNode node, Func resolve) + { + switch (node) + { + case JsonObject obj: + foreach (var property in obj.ToArray()) + { + if (property.Value is not null) + { + var resolved = ResolveInJsonNode(property.Value, resolve); + if (!ReferenceEquals(resolved, property.Value)) + { + obj[property.Key] = resolved; + } + } + } + return obj; + case JsonArray array: + for (var i = 0; i < array.Count; i++) + { + var item = array[i]; + if (item is not null) + { + var resolved = ResolveInJsonNode(item, resolve); + if (!ReferenceEquals(resolved, item)) + { + array[i] = resolved; + } + } + } + return array; + case JsonValue value when value.GetValueKind() == JsonValueKind.String: + var s = value.GetValue(); + var r = resolve(s); + return string.Equals(s, r, StringComparison.Ordinal) ? value : JsonValue.Create(r); + default: + return node; + } + } + + // ${env:NAME} accepts any characters except } so that names like ProgramFiles(x86) work; + // bare ${NAME} is limited to identifiers to avoid matching unrelated ${...} text + [GeneratedRegex(@"(?\$)?\$\{(?:env:(?[^}]+)|(?[A-Za-z_][A-Za-z0-9_]*))\}")] + private static partial Regex PlaceholderRegex(); +} diff --git a/DevProxy.Integration.Tests/MinimalPermissionsUtilsTests.cs b/DevProxy.Integration.Tests/MinimalPermissionsUtilsTests.cs index aa1854cdb..30fbe27a7 100644 --- a/DevProxy.Integration.Tests/MinimalPermissionsUtilsTests.cs +++ b/DevProxy.Integration.Tests/MinimalPermissionsUtilsTests.cs @@ -1,125 +1,125 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using DevProxy.Plugins.Utils; -using Microsoft.Extensions.Logging; -using System.Text.Json; -using Xunit; -using YamlDotNet.RepresentationModel; - -namespace DevProxy.Integration.Tests; - -public sealed class MinimalPermissionsUtilsTests : IDisposable -{ - private const string Variable = "DEVPROXY_TEST_SPEC_TENANT"; - - public MinimalPermissionsUtilsTests() - { - Environment.SetEnvironmentVariable(Variable, "a \"quoted\" value"); - } - - public void Dispose() - { - Environment.SetEnvironmentVariable(Variable, null); - } - - [Fact] - public void ResolveApiSpecVariables_Yaml_ResolvesValuesOnly() - { - var yaml = $$""" - info: - title: "${{{Variable}}}" - ${{{Variable}}}: key - """; - var logger = new WarningLogger(); - - var result = MinimalPermissionsUtils.ResolveApiSpecVariables(yaml, "spec.yaml", [], logger); - - var stream = new YamlStream(); - stream.Load(new StringReader(result)); - var root = (YamlMappingNode)stream.Documents[0].RootNode; - Assert.Equal("a \"quoted\" value", ((YamlMappingNode)root["info"])["title"].ToString()); - Assert.Equal("key", root["${" + Variable + "}"].ToString()); - Assert.Empty(logger.Warnings); - } - - [Theory] - [InlineData("spec.json", """{ "a": "$${TENANT_ID}", "b": "{TENANT_ID}" }""")] - [InlineData("spec.yaml", "a: $${TENANT_ID}\nb: '{TENANT_ID}'")] - public void ResolveApiSpecVariables_LegacyVariablesDontChangeEscapedPlaceholders(string fileName, string contents) - { - var logger = new WarningLogger(); - - var result = MinimalPermissionsUtils.ResolveApiSpecVariables(contents, fileName, new() { ["TENANT_ID"] = "contoso" }, logger); - - var (a, b) = fileName.EndsWith(".json", StringComparison.Ordinal) ? ReadJson(result) : ReadYaml(result); - Assert.Equal("${TENANT_ID}", a); - Assert.Equal("contoso", b); - _ = Assert.Single(logger.Warnings, w => w.Contains("deprecated", StringComparison.Ordinal)); - } - - [Fact] - public void ResolveApiSpecVariables_Yaml_ResolvesUnquotedLegacyVariables() - { - var yaml = """ - servers: - - url: {BASE_URL} - escaped: $${BASE_URL} - """; - var logger = new WarningLogger(); - - var result = MinimalPermissionsUtils.ResolveApiSpecVariables(yaml, "spec.yaml", new() { ["BASE_URL"] = "https://api.contoso.com" }, logger); - - var stream = new YamlStream(); - stream.Load(new StringReader(result)); - var root = (YamlMappingNode)stream.Documents[0].RootNode; - var server = (YamlMappingNode)((YamlSequenceNode)root["servers"])[0]; - Assert.Equal("https://api.contoso.com", Assert.IsType(server["url"]).Value); - Assert.Equal("${BASE_URL}", root["escaped"].ToString()); - _ = Assert.Single(logger.Warnings, w => w.Contains("deprecated", StringComparison.Ordinal)); - } - - [Fact] - public void ResolveApiSpecVariables_EscapedPlaceholderOnly_NoDeprecationWarning() - { - var logger = new WarningLogger(); - - var result = MinimalPermissionsUtils.ResolveApiSpecVariables("""{ "a": "$${TENANT_ID}" }""", "spec.json", new() { ["TENANT_ID"] = "contoso" }, logger); - - using var doc = JsonDocument.Parse(result); - Assert.Equal("${TENANT_ID}", doc.RootElement.GetProperty("a").GetString()); - Assert.Empty(logger.Warnings); - } - - private static (string? A, string? B) ReadJson(string json) - { - using var doc = JsonDocument.Parse(json); - return (doc.RootElement.GetProperty("a").GetString(), doc.RootElement.GetProperty("b").GetString()); - } - - private static (string? A, string? B) ReadYaml(string yaml) - { - var stream = new YamlStream(); - stream.Load(new StringReader(yaml)); - var root = (YamlMappingNode)stream.Documents[0].RootNode; - return (root["a"].ToString(), root["b"].ToString()); - } - - private sealed class WarningLogger : ILogger - { - public List Warnings { get; } = []; - - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) - { - if (logLevel == LogLevel.Warning) - { - Warnings.Add(formatter(state, exception)); - } - } - } +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Plugins.Utils; +using Microsoft.Extensions.Logging; +using System.Text.Json; +using Xunit; +using YamlDotNet.RepresentationModel; + +namespace DevProxy.Integration.Tests; + +public sealed class MinimalPermissionsUtilsTests : IDisposable +{ + private const string Variable = "DEVPROXY_TEST_SPEC_TENANT"; + + public MinimalPermissionsUtilsTests() + { + Environment.SetEnvironmentVariable(Variable, "a \"quoted\" value"); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(Variable, null); + } + + [Fact] + public void ResolveApiSpecVariables_Yaml_ResolvesValuesOnly() + { + var yaml = $$""" + info: + title: "${{{Variable}}}" + ${{{Variable}}}: key + """; + var logger = new WarningLogger(); + + var result = MinimalPermissionsUtils.ResolveApiSpecVariables(yaml, "spec.yaml", [], logger); + + var stream = new YamlStream(); + stream.Load(new StringReader(result)); + var root = (YamlMappingNode)stream.Documents[0].RootNode; + Assert.Equal("a \"quoted\" value", ((YamlMappingNode)root["info"])["title"].ToString()); + Assert.Equal("key", root["${" + Variable + "}"].ToString()); + Assert.Empty(logger.Warnings); + } + + [Theory] + [InlineData("spec.json", """{ "a": "$${TENANT_ID}", "b": "{TENANT_ID}" }""")] + [InlineData("spec.yaml", "a: $${TENANT_ID}\nb: '{TENANT_ID}'")] + public void ResolveApiSpecVariables_LegacyVariablesDontChangeEscapedPlaceholders(string fileName, string contents) + { + var logger = new WarningLogger(); + + var result = MinimalPermissionsUtils.ResolveApiSpecVariables(contents, fileName, new() { ["TENANT_ID"] = "contoso" }, logger); + + var (a, b) = fileName.EndsWith(".json", StringComparison.Ordinal) ? ReadJson(result) : ReadYaml(result); + Assert.Equal("${TENANT_ID}", a); + Assert.Equal("contoso", b); + _ = Assert.Single(logger.Warnings, w => w.Contains("deprecated", StringComparison.Ordinal)); + } + + [Fact] + public void ResolveApiSpecVariables_Yaml_ResolvesUnquotedLegacyVariables() + { + var yaml = """ + servers: + - url: {BASE_URL} + escaped: $${BASE_URL} + """; + var logger = new WarningLogger(); + + var result = MinimalPermissionsUtils.ResolveApiSpecVariables(yaml, "spec.yaml", new() { ["BASE_URL"] = "https://api.contoso.com" }, logger); + + var stream = new YamlStream(); + stream.Load(new StringReader(result)); + var root = (YamlMappingNode)stream.Documents[0].RootNode; + var server = (YamlMappingNode)((YamlSequenceNode)root["servers"])[0]; + Assert.Equal("https://api.contoso.com", Assert.IsType(server["url"]).Value); + Assert.Equal("${BASE_URL}", root["escaped"].ToString()); + _ = Assert.Single(logger.Warnings, w => w.Contains("deprecated", StringComparison.Ordinal)); + } + + [Fact] + public void ResolveApiSpecVariables_EscapedPlaceholderOnly_NoDeprecationWarning() + { + var logger = new WarningLogger(); + + var result = MinimalPermissionsUtils.ResolveApiSpecVariables("""{ "a": "$${TENANT_ID}" }""", "spec.json", new() { ["TENANT_ID"] = "contoso" }, logger); + + using var doc = JsonDocument.Parse(result); + Assert.Equal("${TENANT_ID}", doc.RootElement.GetProperty("a").GetString()); + Assert.Empty(logger.Warnings); + } + + private static (string? A, string? B) ReadJson(string json) + { + using var doc = JsonDocument.Parse(json); + return (doc.RootElement.GetProperty("a").GetString(), doc.RootElement.GetProperty("b").GetString()); + } + + private static (string? A, string? B) ReadYaml(string yaml) + { + var stream = new YamlStream(); + stream.Load(new StringReader(yaml)); + var root = (YamlMappingNode)stream.Documents[0].RootNode; + return (root["a"].ToString(), root["b"].ToString()); + } + + private sealed class WarningLogger : ILogger + { + public List Warnings { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (logLevel == LogLevel.Warning) + { + Warnings.Add(formatter(state, exception)); + } + } + } } \ No newline at end of file diff --git a/DevProxy.Plugins/ApiCenter/ApiCenterClient.cs b/DevProxy.Plugins/ApiCenter/ApiCenterClient.cs index ed843e5f6..fa7ddf5c1 100644 --- a/DevProxy.Plugins/ApiCenter/ApiCenterClient.cs +++ b/DevProxy.Plugins/ApiCenter/ApiCenterClient.cs @@ -1,204 +1,204 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using Azure.Core; -using Azure.Core.Diagnostics; -using Azure.Identity; -using DevProxy.Abstractions.Utils; -using DevProxy.Plugins.Handlers; -using DevProxy.Plugins.Models.ApiCenter; -using Microsoft.Extensions.Logging; -using System.Diagnostics.Tracing; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json; - -namespace DevProxy.Plugins.ApiCenter; - -internal sealed class ApiCenterClientConfiguration -{ - public string ResourceGroupName { get; set; } = ""; - public string ServiceName { get; set; } = ""; - public string SubscriptionId { get; set; } = ""; - public string WorkspaceName { get; set; } = "default"; -} - -internal sealed class ApiCenterClient : IDisposable -{ - private readonly AuthenticationDelegatingHandler _authenticationHandler; - private readonly ApiCenterClientConfiguration _configuration; - private readonly TokenCredential _credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions() - { - ExcludeInteractiveBrowserCredential = true - }); - private readonly HttpClient _httpClient; - private readonly ILogger _logger; - private readonly string[] _scopes = ["https://management.azure.com/.default"]; - - internal ApiCenterClient(ApiCenterClientConfiguration configuration, ILogger logger) - { - if (string.IsNullOrEmpty(configuration.SubscriptionId)) - { - throw new ArgumentException("Specify subscriptionId in the configuration."); - } - if (string.IsNullOrEmpty(configuration.ResourceGroupName)) - { - throw new ArgumentException("Specify resourceGroupName in the configuration."); - } - if (string.IsNullOrEmpty(configuration.ServiceName)) - { - throw new ArgumentException("Specify serviceName in the configuration."); - } - if (string.IsNullOrEmpty(configuration.WorkspaceName)) - { - throw new ArgumentException("Specify workspaceName in the configuration."); - } - - _logger = logger; - - // load configuration from env vars - configuration.SubscriptionId = ResolveEnvironmentVariable(configuration.SubscriptionId, "subscriptionId"); - configuration.ResourceGroupName = ResolveEnvironmentVariable(configuration.ResourceGroupName, "resourceGroupName"); - configuration.ServiceName = ResolveEnvironmentVariable(configuration.ServiceName, "serviceName"); - configuration.WorkspaceName = ResolveEnvironmentVariable(configuration.WorkspaceName, "workspaceName"); - - _configuration = configuration; - - _authenticationHandler = new(_credential, _scopes) - { - InnerHandler = new TracingDelegatingHandler(logger) - { - InnerHandler = new HttpClientHandler() - } - }; - _httpClient = new HttpClient(_authenticationHandler); - - if (_logger.IsEnabled(LogLevel.Debug)) - { - using var azureLogger = AzureEventSourceListener.CreateConsoleLogger(EventLevel.Verbose); - } - } - - private string ResolveEnvironmentVariable(string value, string settingName) - { - if (!value.StartsWith('@')) - { - return value; - } - - var variableName = value[1..]; - _logger.LogWarning( - "Referencing environment variables using @{VariableName} in {SettingName} is deprecated and will be removed in a future version. Use ${{NAME}} instead", - variableName, settingName); - var variableValue = Environment.GetEnvironmentVariable(variableName); - return string.IsNullOrEmpty(variableValue) - ? throw new ArgumentException($"Environment variable {variableName} referenced by {settingName} isn't set.") - : variableValue; - } - - internal Task GetAccessTokenAsync(CancellationToken cancellationToken) => - _authenticationHandler.GetAccessTokenAsync(cancellationToken); - - internal async Task GetApisAsync() - { - _logger.LogInformation("Loading APIs from API Center..."); - - var res = await _httpClient.GetStringAsync($"https://management.azure.com/subscriptions/{_configuration.SubscriptionId}/resourceGroups/{_configuration.ResourceGroupName}/providers/Microsoft.ApiCenter/services/{_configuration.ServiceName}/workspaces/{_configuration.WorkspaceName}/apis?api-version=2024-03-01"); - var collection = JsonSerializer.Deserialize>(res, ProxyUtils.JsonSerializerOptions); - return collection?.Value; - } - - internal async Task GetDefinitionAsync(string definitionId, CancellationToken cancellationToken) - { - _logger.LogDebug("Loading API definition {Id}...", definitionId); - - var res = await _httpClient.GetStringAsync($"https://management.azure.com{definitionId}?api-version=2024-03-01", cancellationToken); - return JsonSerializer.Deserialize(res, ProxyUtils.JsonSerializerOptions); - } - - internal async Task GetDefinitionsAsync(string versionId, CancellationToken cancellationToken) - { - _logger.LogDebug("Loading API definitions for version {Id}...", versionId); - - var res = await _httpClient.GetStringAsync($"https://management.azure.com{versionId}/definitions?api-version=2024-03-01", cancellationToken); - var collection = JsonSerializer.Deserialize>(res, ProxyUtils.JsonSerializerOptions); - return collection?.Value; - } - - internal async Task GetDeploymentsAsync(string apiId, CancellationToken cancellationToken) - { - _logger.LogDebug("Loading API deployments for {ApiName}...", apiId); - - var res = await _httpClient.GetStringAsync($"https://management.azure.com{apiId}/deployments?api-version=2024-03-01", cancellationToken); - var collection = JsonSerializer.Deserialize>(res, ProxyUtils.JsonSerializerOptions); - return collection?.Value; - } - - internal async Task GetVersionsAsync(string apiId, CancellationToken cancellationToken) - { - _logger.LogDebug("Loading API versions for {ApiName}...", apiId); - - var res = await _httpClient.GetStringAsync($"https://management.azure.com{apiId}/versions?api-version=2024-03-01", cancellationToken); - var collection = JsonSerializer.Deserialize>(res, ProxyUtils.JsonSerializerOptions); - return collection?.Value; - } - - internal async Task PostExportSpecificationAsync(string definitionId, CancellationToken cancellationToken) - { - var definitionRes = await _httpClient.PostAsync($"https://management.azure.com{definitionId}/exportSpecification?api-version=2024-03-01", null, cancellationToken); - return await definitionRes.Content.ReadFromJsonAsync(ProxyUtils.JsonSerializerOptions, cancellationToken); - } - - internal async Task PostImportSpecificationAsync(ApiSpecImport apiSpecImport, string definitionId, CancellationToken cancellationToken) - { - using var content = new StringContent(JsonSerializer.Serialize(apiSpecImport, ProxyUtils.JsonSerializerOptions), Encoding.UTF8, "application/json"); - return await _httpClient.PostAsync($"https://management.azure.com{definitionId}/importSpecification?api-version=2024-03-01", content, cancellationToken); - } - - internal async Task PutApiAsync(Api api, string apiName, CancellationToken cancellationToken) - { - using var content = new StringContent(JsonSerializer.Serialize(api, ProxyUtils.JsonSerializerOptions), Encoding.UTF8, "application/json"); - var res = await _httpClient.PutAsync($"https://management.azure.com/subscriptions/{_configuration.SubscriptionId}/resourceGroups/{_configuration.ResourceGroupName}/providers/Microsoft.ApiCenter/services/{_configuration.ServiceName}/workspaces/{_configuration.WorkspaceName}/apis/{apiName}?api-version=2024-03-01", content, cancellationToken); - - var resContent = await res.Content.ReadAsStringAsync(cancellationToken); - _logger.LogDebug("{Response}", resContent); - - return res.IsSuccessStatusCode ? - JsonSerializer.Deserialize(resContent, ProxyUtils.JsonSerializerOptions) : - null; - } - - internal async Task PutVersionAsync(ApiVersion apiVersion, string apiId, string apiName, CancellationToken cancellationToken) - { - using var content = new StringContent(JsonSerializer.Serialize(apiVersion, ProxyUtils.JsonSerializerOptions), Encoding.UTF8, "application/json"); - var res = await _httpClient.PutAsync($"https://management.azure.com{apiId}/versions/{apiName}?api-version=2024-03-01", content, cancellationToken); - - var resContent = await res.Content.ReadAsStringAsync(cancellationToken); - _logger.LogDebug("{Response}", resContent); - - return res.IsSuccessStatusCode ? - JsonSerializer.Deserialize(resContent, ProxyUtils.JsonSerializerOptions) : - null; - } - - internal async Task PutDefinitionAsync(ApiDefinition apiDefinition, string apiVersionId, string definitionName, CancellationToken cancellationToken) - { - using var content = new StringContent(JsonSerializer.Serialize(apiDefinition, ProxyUtils.JsonSerializerOptions), Encoding.UTF8, "application/json"); - var res = await _httpClient.PutAsync($"https://management.azure.com{apiVersionId}/definitions/{definitionName}?api-version=2024-03-01", content, cancellationToken); - - var resContent = await res.Content.ReadAsStringAsync(cancellationToken); - _logger.LogDebug("{Response}", resContent); - - return res.IsSuccessStatusCode ? - JsonSerializer.Deserialize(resContent, ProxyUtils.JsonSerializerOptions) : - null; - } - - public void Dispose() - { - _httpClient.Dispose(); - _authenticationHandler.Dispose(); - GC.SuppressFinalize(this); - } +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Azure.Core; +using Azure.Core.Diagnostics; +using Azure.Identity; +using DevProxy.Abstractions.Utils; +using DevProxy.Plugins.Handlers; +using DevProxy.Plugins.Models.ApiCenter; +using Microsoft.Extensions.Logging; +using System.Diagnostics.Tracing; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; + +namespace DevProxy.Plugins.ApiCenter; + +internal sealed class ApiCenterClientConfiguration +{ + public string ResourceGroupName { get; set; } = ""; + public string ServiceName { get; set; } = ""; + public string SubscriptionId { get; set; } = ""; + public string WorkspaceName { get; set; } = "default"; +} + +internal sealed class ApiCenterClient : IDisposable +{ + private readonly AuthenticationDelegatingHandler _authenticationHandler; + private readonly ApiCenterClientConfiguration _configuration; + private readonly TokenCredential _credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions() + { + ExcludeInteractiveBrowserCredential = true + }); + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly string[] _scopes = ["https://management.azure.com/.default"]; + + internal ApiCenterClient(ApiCenterClientConfiguration configuration, ILogger logger) + { + if (string.IsNullOrEmpty(configuration.SubscriptionId)) + { + throw new ArgumentException("Specify subscriptionId in the configuration."); + } + if (string.IsNullOrEmpty(configuration.ResourceGroupName)) + { + throw new ArgumentException("Specify resourceGroupName in the configuration."); + } + if (string.IsNullOrEmpty(configuration.ServiceName)) + { + throw new ArgumentException("Specify serviceName in the configuration."); + } + if (string.IsNullOrEmpty(configuration.WorkspaceName)) + { + throw new ArgumentException("Specify workspaceName in the configuration."); + } + + _logger = logger; + + // load configuration from env vars + configuration.SubscriptionId = ResolveEnvironmentVariable(configuration.SubscriptionId, "subscriptionId"); + configuration.ResourceGroupName = ResolveEnvironmentVariable(configuration.ResourceGroupName, "resourceGroupName"); + configuration.ServiceName = ResolveEnvironmentVariable(configuration.ServiceName, "serviceName"); + configuration.WorkspaceName = ResolveEnvironmentVariable(configuration.WorkspaceName, "workspaceName"); + + _configuration = configuration; + + _authenticationHandler = new(_credential, _scopes) + { + InnerHandler = new TracingDelegatingHandler(logger) + { + InnerHandler = new HttpClientHandler() + } + }; + _httpClient = new HttpClient(_authenticationHandler); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + using var azureLogger = AzureEventSourceListener.CreateConsoleLogger(EventLevel.Verbose); + } + } + + private string ResolveEnvironmentVariable(string value, string settingName) + { + if (!value.StartsWith('@')) + { + return value; + } + + var variableName = value[1..]; + _logger.LogWarning( + "Referencing environment variables using @{VariableName} in {SettingName} is deprecated and will be removed in a future version. Use ${{NAME}} instead", + variableName, settingName); + var variableValue = Environment.GetEnvironmentVariable(variableName); + return string.IsNullOrEmpty(variableValue) + ? throw new ArgumentException($"Environment variable {variableName} referenced by {settingName} isn't set.") + : variableValue; + } + + internal Task GetAccessTokenAsync(CancellationToken cancellationToken) => + _authenticationHandler.GetAccessTokenAsync(cancellationToken); + + internal async Task GetApisAsync() + { + _logger.LogInformation("Loading APIs from API Center..."); + + var res = await _httpClient.GetStringAsync($"https://management.azure.com/subscriptions/{_configuration.SubscriptionId}/resourceGroups/{_configuration.ResourceGroupName}/providers/Microsoft.ApiCenter/services/{_configuration.ServiceName}/workspaces/{_configuration.WorkspaceName}/apis?api-version=2024-03-01"); + var collection = JsonSerializer.Deserialize>(res, ProxyUtils.JsonSerializerOptions); + return collection?.Value; + } + + internal async Task GetDefinitionAsync(string definitionId, CancellationToken cancellationToken) + { + _logger.LogDebug("Loading API definition {Id}...", definitionId); + + var res = await _httpClient.GetStringAsync($"https://management.azure.com{definitionId}?api-version=2024-03-01", cancellationToken); + return JsonSerializer.Deserialize(res, ProxyUtils.JsonSerializerOptions); + } + + internal async Task GetDefinitionsAsync(string versionId, CancellationToken cancellationToken) + { + _logger.LogDebug("Loading API definitions for version {Id}...", versionId); + + var res = await _httpClient.GetStringAsync($"https://management.azure.com{versionId}/definitions?api-version=2024-03-01", cancellationToken); + var collection = JsonSerializer.Deserialize>(res, ProxyUtils.JsonSerializerOptions); + return collection?.Value; + } + + internal async Task GetDeploymentsAsync(string apiId, CancellationToken cancellationToken) + { + _logger.LogDebug("Loading API deployments for {ApiName}...", apiId); + + var res = await _httpClient.GetStringAsync($"https://management.azure.com{apiId}/deployments?api-version=2024-03-01", cancellationToken); + var collection = JsonSerializer.Deserialize>(res, ProxyUtils.JsonSerializerOptions); + return collection?.Value; + } + + internal async Task GetVersionsAsync(string apiId, CancellationToken cancellationToken) + { + _logger.LogDebug("Loading API versions for {ApiName}...", apiId); + + var res = await _httpClient.GetStringAsync($"https://management.azure.com{apiId}/versions?api-version=2024-03-01", cancellationToken); + var collection = JsonSerializer.Deserialize>(res, ProxyUtils.JsonSerializerOptions); + return collection?.Value; + } + + internal async Task PostExportSpecificationAsync(string definitionId, CancellationToken cancellationToken) + { + var definitionRes = await _httpClient.PostAsync($"https://management.azure.com{definitionId}/exportSpecification?api-version=2024-03-01", null, cancellationToken); + return await definitionRes.Content.ReadFromJsonAsync(ProxyUtils.JsonSerializerOptions, cancellationToken); + } + + internal async Task PostImportSpecificationAsync(ApiSpecImport apiSpecImport, string definitionId, CancellationToken cancellationToken) + { + using var content = new StringContent(JsonSerializer.Serialize(apiSpecImport, ProxyUtils.JsonSerializerOptions), Encoding.UTF8, "application/json"); + return await _httpClient.PostAsync($"https://management.azure.com{definitionId}/importSpecification?api-version=2024-03-01", content, cancellationToken); + } + + internal async Task PutApiAsync(Api api, string apiName, CancellationToken cancellationToken) + { + using var content = new StringContent(JsonSerializer.Serialize(api, ProxyUtils.JsonSerializerOptions), Encoding.UTF8, "application/json"); + var res = await _httpClient.PutAsync($"https://management.azure.com/subscriptions/{_configuration.SubscriptionId}/resourceGroups/{_configuration.ResourceGroupName}/providers/Microsoft.ApiCenter/services/{_configuration.ServiceName}/workspaces/{_configuration.WorkspaceName}/apis/{apiName}?api-version=2024-03-01", content, cancellationToken); + + var resContent = await res.Content.ReadAsStringAsync(cancellationToken); + _logger.LogDebug("{Response}", resContent); + + return res.IsSuccessStatusCode ? + JsonSerializer.Deserialize(resContent, ProxyUtils.JsonSerializerOptions) : + null; + } + + internal async Task PutVersionAsync(ApiVersion apiVersion, string apiId, string apiName, CancellationToken cancellationToken) + { + using var content = new StringContent(JsonSerializer.Serialize(apiVersion, ProxyUtils.JsonSerializerOptions), Encoding.UTF8, "application/json"); + var res = await _httpClient.PutAsync($"https://management.azure.com{apiId}/versions/{apiName}?api-version=2024-03-01", content, cancellationToken); + + var resContent = await res.Content.ReadAsStringAsync(cancellationToken); + _logger.LogDebug("{Response}", resContent); + + return res.IsSuccessStatusCode ? + JsonSerializer.Deserialize(resContent, ProxyUtils.JsonSerializerOptions) : + null; + } + + internal async Task PutDefinitionAsync(ApiDefinition apiDefinition, string apiVersionId, string definitionName, CancellationToken cancellationToken) + { + using var content = new StringContent(JsonSerializer.Serialize(apiDefinition, ProxyUtils.JsonSerializerOptions), Encoding.UTF8, "application/json"); + var res = await _httpClient.PutAsync($"https://management.azure.com{apiVersionId}/definitions/{definitionName}?api-version=2024-03-01", content, cancellationToken); + + var resContent = await res.Content.ReadAsStringAsync(cancellationToken); + _logger.LogDebug("{Response}", resContent); + + return res.IsSuccessStatusCode ? + JsonSerializer.Deserialize(resContent, ProxyUtils.JsonSerializerOptions) : + null; + } + + public void Dispose() + { + _httpClient.Dispose(); + _authenticationHandler.Dispose(); + GC.SuppressFinalize(this); + } } \ No newline at end of file diff --git a/DevProxy.Plugins/Manipulation/RewritesLoader.cs b/DevProxy.Plugins/Manipulation/RewritesLoader.cs index 9b45f7bcc..a9bf172de 100644 --- a/DevProxy.Plugins/Manipulation/RewritesLoader.cs +++ b/DevProxy.Plugins/Manipulation/RewritesLoader.cs @@ -1,47 +1,47 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using DevProxy.Abstractions.Plugins; -using DevProxy.Abstractions.Proxy; -using DevProxy.Abstractions.Utils; -using Microsoft.Extensions.Logging; -using System.Text.Json; - -namespace DevProxy.Plugins.Manipulation; - -internal sealed class RewritesLoader( - HttpClient httpClient, - ILogger logger, - RewritePluginConfiguration configuration, - IProxyConfiguration proxyConfiguration) : - BaseLoader(httpClient, logger, proxyConfiguration) -{ - private readonly RewritePluginConfiguration _configuration = configuration; - - protected override string FilePath => _configuration.RewritesFile; - // ${name} in out.url is a regex named group reference, so leave placeholders - // as-is. RewritePlugin resolves ${env:NAME} per request. - protected override bool ResolveEnvironmentVariables => false; - - protected override void LoadData(string fileContents) - { - try - { - var rewritesConfig = JsonSerializer.Deserialize(fileContents, ProxyUtils.JsonSerializerOptions); - var configRewrites = rewritesConfig?.Rewrites; - if (configRewrites is not null) - { - _configuration.Rewrites = configRewrites; - if (Logger.IsEnabled(LogLevel.Information)) - { - Logger.LogInformation("Rewrites for {ConfigResponseCount} url patterns loaded from {RewritesFile}", configRewrites.Count(), _configuration.RewritesFile); - } - } - } - catch (Exception ex) - { - Logger.LogError(ex, "An error has occurred while reading {RewritesFile}:", _configuration.RewritesFile); - } - } -} +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Plugins; +using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Utils; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace DevProxy.Plugins.Manipulation; + +internal sealed class RewritesLoader( + HttpClient httpClient, + ILogger logger, + RewritePluginConfiguration configuration, + IProxyConfiguration proxyConfiguration) : + BaseLoader(httpClient, logger, proxyConfiguration) +{ + private readonly RewritePluginConfiguration _configuration = configuration; + + protected override string FilePath => _configuration.RewritesFile; + // ${name} in out.url is a regex named group reference, so leave placeholders + // as-is. RewritePlugin resolves ${env:NAME} per request. + protected override bool ResolveEnvironmentVariables => false; + + protected override void LoadData(string fileContents) + { + try + { + var rewritesConfig = JsonSerializer.Deserialize(fileContents, ProxyUtils.JsonSerializerOptions); + var configRewrites = rewritesConfig?.Rewrites; + if (configRewrites is not null) + { + _configuration.Rewrites = configRewrites; + if (Logger.IsEnabled(LogLevel.Information)) + { + Logger.LogInformation("Rewrites for {ConfigResponseCount} url patterns loaded from {RewritesFile}", configRewrites.Count(), _configuration.RewritesFile); + } + } + } + catch (Exception ex) + { + Logger.LogError(ex, "An error has occurred while reading {RewritesFile}:", _configuration.RewritesFile); + } + } +} diff --git a/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs b/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs index 936682ed5..8e6c3faa7 100644 --- a/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs +++ b/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs @@ -1,330 +1,330 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using DevProxy.Abstractions.Plugins; -using DevProxy.Abstractions.Proxy; -using DevProxy.Abstractions.Utils; -using DevProxy.Plugins.Models; -using DevProxy.Plugins.Utils; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.OpenApi; -using Microsoft.OpenApi.Reader; - -namespace DevProxy.Plugins.Reporting; - -public sealed class MinimalPermissionsGuidancePluginConfiguration -{ - public string? ApiSpecsFolderPath { get; set; } - public IEnumerable? PermissionsToExclude { get; set; } - public string? SchemeName { get; set; } -} - -public sealed class MinimalPermissionsGuidancePlugin( - HttpClient httpClient, - ILogger logger, - ISet urlsToWatch, - IProxyConfiguration proxyConfiguration, - IConfigurationSection pluginConfigSection) : - BaseReportingPlugin( - httpClient, - logger, - urlsToWatch, - proxyConfiguration, - pluginConfigSection) -{ - private Dictionary? _apiSpecsByUrl; - - public override string Name => nameof(MinimalPermissionsGuidancePlugin); - - public override async Task InitializeAsync(InitArgs e, CancellationToken cancellationToken) - { - await base.InitializeAsync(e, cancellationToken); - - InitializeApiSpecsFolderPath(); - InitializePermissionsToExclude(); - } - - public override async Task AfterRecordingStopAsync(RecordingArgs e, CancellationToken cancellationToken) - { - Logger.LogTrace("{Method} called", nameof(AfterRecordingStopAsync)); - - ArgumentNullException.ThrowIfNull(e); - - var interceptedRequests = e.RequestLogs - .Where(l => - l.MessageType == MessageType.InterceptedRequest && - !l.Message.StartsWith("OPTIONS", StringComparison.OrdinalIgnoreCase) && - l.Context?.Session is not null && - ProxyUtils.MatchesUrlToWatch(UrlsToWatch, l.Context.Session.Request.RequestUri.AbsoluteUri) && - l.Context.Session.Request.Headers.Any(h => h.Name.Equals("authorization", StringComparison.OrdinalIgnoreCase)) - ); - if (!interceptedRequests.Any()) - { - Logger.LogRequest("No requests to process", MessageType.Skipped); - return; - } - - Logger.LogInformation("Checking if recorded API requests use minimal permissions as defined in API specs..."); - - _apiSpecsByUrl ??= await LoadApiSpecsAsync(Configuration.ApiSpecsFolderPath!, cancellationToken); - if (_apiSpecsByUrl is null || _apiSpecsByUrl.Count == 0) - { - Logger.LogWarning("No API definitions found in the specified folder."); - return; - } - - var (requestsByApiSpec, unmatchedApiSpecRequests) = GetRequestsByApiSpec(interceptedRequests, _apiSpecsByUrl); - - var errors = new List(); - var results = new List(); - var unmatchedRequests = new List( - unmatchedApiSpecRequests.Select(r => r.Message) - ); - - foreach (var (apiSpec, requests) in requestsByApiSpec) - { - var minimalPermissions = apiSpec.CheckMinimalPermissions(requests, Logger, Configuration.SchemeName); - - IEnumerable excessivePermissions = [.. minimalPermissions.TokenPermissions - .Except(Configuration.PermissionsToExclude ?? []) - .Except(minimalPermissions.MinimalScopes) - ]; - - var result = new MinimalPermissionsGuidancePluginReportApiResult - { - ApiName = GetApiName(minimalPermissions.OperationsFromRequests.Any() ? - minimalPermissions.OperationsFromRequests.First().OriginalUrl : null), - Requests = [.. minimalPermissions.OperationsFromRequests - .Select(o => $"{o.Method} {o.OriginalUrl}") - .Distinct()], - TokenPermissions = [.. minimalPermissions.TokenPermissions.Distinct()], - MinimalPermissions = minimalPermissions.MinimalScopes, - ExcessivePermissions = excessivePermissions, - UsesMinimalPermissions = !excessivePermissions.Any(), - SchemeName = Configuration.SchemeName - }; - results.Add(result); - - var unmatchedApiRequests = minimalPermissions.OperationsFromRequests - .Where(o => minimalPermissions.UnmatchedOperations.Contains($"{o.Method} {o.TokenizedUrl}")) - .Select(o => $"{o.Method} {o.OriginalUrl}"); - unmatchedRequests.AddRange(unmatchedApiRequests); - errors.AddRange(minimalPermissions.Errors); - - if (result.UsesMinimalPermissions) - { - if (string.IsNullOrWhiteSpace(Configuration.SchemeName)) - { - if (Logger.IsEnabled(LogLevel.Information)) - { - Logger.LogInformation( - "API {ApiName} is called with minimal permissions: {MinimalPermissions}", - result.ApiName, - string.Join(", ", result.MinimalPermissions) - ); - } - } - else - { - if (Logger.IsEnabled(LogLevel.Information)) - { - Logger.LogInformation( - "API {ApiName} is called with minimal permissions of '{SchemeName}' scheme: {MinimalPermissions}", - result.ApiName, - Configuration.SchemeName, - string.Join(", ", result.MinimalPermissions) - ); - } - } - } - else - { - if (string.IsNullOrWhiteSpace(Configuration.SchemeName)) - { - if (Logger.IsEnabled(LogLevel.Warning)) - { - Logger.LogWarning( - "Calling API {ApiName} with excessive permissions: {ExcessivePermissions}. Minimal permissions are: {MinimalPermissions}", - result.ApiName, - string.Join(", ", result.ExcessivePermissions), - string.Join(", ", result.MinimalPermissions) - ); - } - } - else - { - if (Logger.IsEnabled(LogLevel.Warning)) - { - Logger.LogWarning( - "Calling API {ApiName} with excessive permissions of '{SchemeName}' scheme: {ExcessivePermissions}. Minimal permissions are: {MinimalPermissions}", - result.ApiName, - Configuration.SchemeName, - string.Join(", ", result.ExcessivePermissions), - string.Join(", ", result.MinimalPermissions) - ); - } - } - } - - if (unmatchedApiRequests.Any() && - Logger.IsEnabled(LogLevel.Warning)) - { - Logger.LogWarning( - "Unmatched requests for API {ApiName}:{NewLine}- {UnmatchedRequests}", - result.ApiName, - Environment.NewLine, - string.Join($"{Environment.NewLine}- ", unmatchedApiRequests) - ); - } - - if (minimalPermissions.Errors.Any() && - Logger.IsEnabled(LogLevel.Warning)) - { - Logger.LogWarning( - "Errors for API {ApiName}:{NewLine}- {Errors}", - result.ApiName, - Environment.NewLine, - string.Join($"{Environment.NewLine}- ", minimalPermissions.Errors.Select(e => $"{e.Request}: {e.Error}")) - ); - } - } - - var report = new MinimalPermissionsGuidancePluginReport() - { - Results = [.. results], - UnmatchedRequests = [.. unmatchedRequests], - Errors = [.. errors], - ExcludedPermissions = Configuration.PermissionsToExclude - }; - - if (Configuration.PermissionsToExclude?.Any() == true && - Logger.IsEnabled(LogLevel.Information)) - { - Logger.LogInformation("Excluding the following permissions: {Permissions}", string.Join(", ", Configuration.PermissionsToExclude)); - } - - StoreReport(report, e); - - Logger.LogTrace("Left {Name}", nameof(AfterRecordingStopAsync)); - } - - private void InitializeApiSpecsFolderPath() - { - if (string.IsNullOrWhiteSpace(Configuration.ApiSpecsFolderPath)) - { - Enabled = false; - throw new InvalidOperationException("ApiSpecsFolderPath is required."); - } - - Configuration.ApiSpecsFolderPath = ProxyUtils.GetFullPath(Configuration.ApiSpecsFolderPath, ProxyConfiguration.ConfigFile); - if (!Path.Exists(Configuration.ApiSpecsFolderPath)) - { - Enabled = false; - throw new FileNotFoundException($"ApiSpecsFolderPath '{Configuration.ApiSpecsFolderPath}' does not exist."); - } - } - - private void InitializePermissionsToExclude() - { - var key = nameof(MinimalPermissionsGuidancePluginConfiguration.PermissionsToExclude) - .ToCamelCase(); - - string[] defaultPermissionsToExclude = ["profile", "openid", "offline_access", "email"]; - Configuration.PermissionsToExclude = GetConfigurationValue(key, Configuration.PermissionsToExclude, defaultPermissionsToExclude); - } - - private async Task> LoadApiSpecsAsync(string apiSpecsFolderPath, CancellationToken cancellationToken) - { - var apiDefinitions = new Dictionary(); - foreach (var file in Directory.EnumerateFiles(apiSpecsFolderPath, "*.*", SearchOption.AllDirectories)) - { - cancellationToken.ThrowIfCancellationRequested(); - - var extension = Path.GetExtension(file); - if (!extension.Equals(".json", StringComparison.OrdinalIgnoreCase) && - !extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase) && - !extension.Equals(".yml", StringComparison.OrdinalIgnoreCase)) - { - Logger.LogDebug("Skipping file '{File}' because it is not a JSON or YAML file", file); - continue; - } - - Logger.LogDebug("Processing file '{File}'...", file); - try - { - var fileContents = await File.ReadAllTextAsync(file, cancellationToken); - fileContents = MinimalPermissionsUtils.ResolveApiSpecVariables(fileContents, file, ProxyConfiguration.Env, Logger); - - var settings = new OpenApiReaderSettings(); - settings.AddYamlReader(); - var readResult = OpenApiDocument.Parse(fileContents, null, settings); - var apiDefinition = readResult.Document; - if (apiDefinition is null) - { - continue; - } - if (apiDefinition.Servers is null || apiDefinition.Servers.Count == 0) - { - Logger.LogDebug("No servers found in API definition file '{File}'", file); - continue; - } - foreach (var server in apiDefinition.Servers) - { - if (server.Url is null) - { - Logger.LogDebug("No URL found for server '{Server}'", server.Description ?? "unnamed"); - continue; - } - apiDefinitions[server.Url] = apiDefinition; - } - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to load API definition from file '{File}'", file); - } - } - return apiDefinitions; - } - - private (Dictionary> RequestsByApiSpec, IEnumerable UnmatchedRequests) GetRequestsByApiSpec(IEnumerable interceptedRequests, Dictionary apiSpecsByUrl) - { - var unmatchedRequests = new List(); - var requestsByApiSpec = new Dictionary>(); - foreach (var request in interceptedRequests) - { - var url = request.Message.Split(' ')[1]; - Logger.LogDebug("Matching request {RequestUrl} to API specs...", url); - - var matchingKey = apiSpecsByUrl.Keys.FirstOrDefault(url.StartsWith); - if (matchingKey is null) - { - Logger.LogDebug("No matching API spec found for {RequestUrl}", url); - unmatchedRequests.Add(request); - continue; - } - - if (!requestsByApiSpec.TryGetValue(apiSpecsByUrl[matchingKey], out var value)) - { - value = []; - requestsByApiSpec[apiSpecsByUrl[matchingKey]] = value; - } - - value.Add(request); - } - - return (requestsByApiSpec, unmatchedRequests); - } - - private static string GetApiName(string? url) - { - if (string.IsNullOrWhiteSpace(url)) - { - return "Unknown"; - } - - var uri = new Uri(url); - return uri.Authority; - } -} +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Plugins; +using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Utils; +using DevProxy.Plugins.Models; +using DevProxy.Plugins.Utils; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; + +namespace DevProxy.Plugins.Reporting; + +public sealed class MinimalPermissionsGuidancePluginConfiguration +{ + public string? ApiSpecsFolderPath { get; set; } + public IEnumerable? PermissionsToExclude { get; set; } + public string? SchemeName { get; set; } +} + +public sealed class MinimalPermissionsGuidancePlugin( + HttpClient httpClient, + ILogger logger, + ISet urlsToWatch, + IProxyConfiguration proxyConfiguration, + IConfigurationSection pluginConfigSection) : + BaseReportingPlugin( + httpClient, + logger, + urlsToWatch, + proxyConfiguration, + pluginConfigSection) +{ + private Dictionary? _apiSpecsByUrl; + + public override string Name => nameof(MinimalPermissionsGuidancePlugin); + + public override async Task InitializeAsync(InitArgs e, CancellationToken cancellationToken) + { + await base.InitializeAsync(e, cancellationToken); + + InitializeApiSpecsFolderPath(); + InitializePermissionsToExclude(); + } + + public override async Task AfterRecordingStopAsync(RecordingArgs e, CancellationToken cancellationToken) + { + Logger.LogTrace("{Method} called", nameof(AfterRecordingStopAsync)); + + ArgumentNullException.ThrowIfNull(e); + + var interceptedRequests = e.RequestLogs + .Where(l => + l.MessageType == MessageType.InterceptedRequest && + !l.Message.StartsWith("OPTIONS", StringComparison.OrdinalIgnoreCase) && + l.Context?.Session is not null && + ProxyUtils.MatchesUrlToWatch(UrlsToWatch, l.Context.Session.Request.RequestUri.AbsoluteUri) && + l.Context.Session.Request.Headers.Any(h => h.Name.Equals("authorization", StringComparison.OrdinalIgnoreCase)) + ); + if (!interceptedRequests.Any()) + { + Logger.LogRequest("No requests to process", MessageType.Skipped); + return; + } + + Logger.LogInformation("Checking if recorded API requests use minimal permissions as defined in API specs..."); + + _apiSpecsByUrl ??= await LoadApiSpecsAsync(Configuration.ApiSpecsFolderPath!, cancellationToken); + if (_apiSpecsByUrl is null || _apiSpecsByUrl.Count == 0) + { + Logger.LogWarning("No API definitions found in the specified folder."); + return; + } + + var (requestsByApiSpec, unmatchedApiSpecRequests) = GetRequestsByApiSpec(interceptedRequests, _apiSpecsByUrl); + + var errors = new List(); + var results = new List(); + var unmatchedRequests = new List( + unmatchedApiSpecRequests.Select(r => r.Message) + ); + + foreach (var (apiSpec, requests) in requestsByApiSpec) + { + var minimalPermissions = apiSpec.CheckMinimalPermissions(requests, Logger, Configuration.SchemeName); + + IEnumerable excessivePermissions = [.. minimalPermissions.TokenPermissions + .Except(Configuration.PermissionsToExclude ?? []) + .Except(minimalPermissions.MinimalScopes) + ]; + + var result = new MinimalPermissionsGuidancePluginReportApiResult + { + ApiName = GetApiName(minimalPermissions.OperationsFromRequests.Any() ? + minimalPermissions.OperationsFromRequests.First().OriginalUrl : null), + Requests = [.. minimalPermissions.OperationsFromRequests + .Select(o => $"{o.Method} {o.OriginalUrl}") + .Distinct()], + TokenPermissions = [.. minimalPermissions.TokenPermissions.Distinct()], + MinimalPermissions = minimalPermissions.MinimalScopes, + ExcessivePermissions = excessivePermissions, + UsesMinimalPermissions = !excessivePermissions.Any(), + SchemeName = Configuration.SchemeName + }; + results.Add(result); + + var unmatchedApiRequests = minimalPermissions.OperationsFromRequests + .Where(o => minimalPermissions.UnmatchedOperations.Contains($"{o.Method} {o.TokenizedUrl}")) + .Select(o => $"{o.Method} {o.OriginalUrl}"); + unmatchedRequests.AddRange(unmatchedApiRequests); + errors.AddRange(minimalPermissions.Errors); + + if (result.UsesMinimalPermissions) + { + if (string.IsNullOrWhiteSpace(Configuration.SchemeName)) + { + if (Logger.IsEnabled(LogLevel.Information)) + { + Logger.LogInformation( + "API {ApiName} is called with minimal permissions: {MinimalPermissions}", + result.ApiName, + string.Join(", ", result.MinimalPermissions) + ); + } + } + else + { + if (Logger.IsEnabled(LogLevel.Information)) + { + Logger.LogInformation( + "API {ApiName} is called with minimal permissions of '{SchemeName}' scheme: {MinimalPermissions}", + result.ApiName, + Configuration.SchemeName, + string.Join(", ", result.MinimalPermissions) + ); + } + } + } + else + { + if (string.IsNullOrWhiteSpace(Configuration.SchemeName)) + { + if (Logger.IsEnabled(LogLevel.Warning)) + { + Logger.LogWarning( + "Calling API {ApiName} with excessive permissions: {ExcessivePermissions}. Minimal permissions are: {MinimalPermissions}", + result.ApiName, + string.Join(", ", result.ExcessivePermissions), + string.Join(", ", result.MinimalPermissions) + ); + } + } + else + { + if (Logger.IsEnabled(LogLevel.Warning)) + { + Logger.LogWarning( + "Calling API {ApiName} with excessive permissions of '{SchemeName}' scheme: {ExcessivePermissions}. Minimal permissions are: {MinimalPermissions}", + result.ApiName, + Configuration.SchemeName, + string.Join(", ", result.ExcessivePermissions), + string.Join(", ", result.MinimalPermissions) + ); + } + } + } + + if (unmatchedApiRequests.Any() && + Logger.IsEnabled(LogLevel.Warning)) + { + Logger.LogWarning( + "Unmatched requests for API {ApiName}:{NewLine}- {UnmatchedRequests}", + result.ApiName, + Environment.NewLine, + string.Join($"{Environment.NewLine}- ", unmatchedApiRequests) + ); + } + + if (minimalPermissions.Errors.Any() && + Logger.IsEnabled(LogLevel.Warning)) + { + Logger.LogWarning( + "Errors for API {ApiName}:{NewLine}- {Errors}", + result.ApiName, + Environment.NewLine, + string.Join($"{Environment.NewLine}- ", minimalPermissions.Errors.Select(e => $"{e.Request}: {e.Error}")) + ); + } + } + + var report = new MinimalPermissionsGuidancePluginReport() + { + Results = [.. results], + UnmatchedRequests = [.. unmatchedRequests], + Errors = [.. errors], + ExcludedPermissions = Configuration.PermissionsToExclude + }; + + if (Configuration.PermissionsToExclude?.Any() == true && + Logger.IsEnabled(LogLevel.Information)) + { + Logger.LogInformation("Excluding the following permissions: {Permissions}", string.Join(", ", Configuration.PermissionsToExclude)); + } + + StoreReport(report, e); + + Logger.LogTrace("Left {Name}", nameof(AfterRecordingStopAsync)); + } + + private void InitializeApiSpecsFolderPath() + { + if (string.IsNullOrWhiteSpace(Configuration.ApiSpecsFolderPath)) + { + Enabled = false; + throw new InvalidOperationException("ApiSpecsFolderPath is required."); + } + + Configuration.ApiSpecsFolderPath = ProxyUtils.GetFullPath(Configuration.ApiSpecsFolderPath, ProxyConfiguration.ConfigFile); + if (!Path.Exists(Configuration.ApiSpecsFolderPath)) + { + Enabled = false; + throw new FileNotFoundException($"ApiSpecsFolderPath '{Configuration.ApiSpecsFolderPath}' does not exist."); + } + } + + private void InitializePermissionsToExclude() + { + var key = nameof(MinimalPermissionsGuidancePluginConfiguration.PermissionsToExclude) + .ToCamelCase(); + + string[] defaultPermissionsToExclude = ["profile", "openid", "offline_access", "email"]; + Configuration.PermissionsToExclude = GetConfigurationValue(key, Configuration.PermissionsToExclude, defaultPermissionsToExclude); + } + + private async Task> LoadApiSpecsAsync(string apiSpecsFolderPath, CancellationToken cancellationToken) + { + var apiDefinitions = new Dictionary(); + foreach (var file in Directory.EnumerateFiles(apiSpecsFolderPath, "*.*", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var extension = Path.GetExtension(file); + if (!extension.Equals(".json", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".yml", StringComparison.OrdinalIgnoreCase)) + { + Logger.LogDebug("Skipping file '{File}' because it is not a JSON or YAML file", file); + continue; + } + + Logger.LogDebug("Processing file '{File}'...", file); + try + { + var fileContents = await File.ReadAllTextAsync(file, cancellationToken); + fileContents = MinimalPermissionsUtils.ResolveApiSpecVariables(fileContents, file, ProxyConfiguration.Env, Logger); + + var settings = new OpenApiReaderSettings(); + settings.AddYamlReader(); + var readResult = OpenApiDocument.Parse(fileContents, null, settings); + var apiDefinition = readResult.Document; + if (apiDefinition is null) + { + continue; + } + if (apiDefinition.Servers is null || apiDefinition.Servers.Count == 0) + { + Logger.LogDebug("No servers found in API definition file '{File}'", file); + continue; + } + foreach (var server in apiDefinition.Servers) + { + if (server.Url is null) + { + Logger.LogDebug("No URL found for server '{Server}'", server.Description ?? "unnamed"); + continue; + } + apiDefinitions[server.Url] = apiDefinition; + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to load API definition from file '{File}'", file); + } + } + return apiDefinitions; + } + + private (Dictionary> RequestsByApiSpec, IEnumerable UnmatchedRequests) GetRequestsByApiSpec(IEnumerable interceptedRequests, Dictionary apiSpecsByUrl) + { + var unmatchedRequests = new List(); + var requestsByApiSpec = new Dictionary>(); + foreach (var request in interceptedRequests) + { + var url = request.Message.Split(' ')[1]; + Logger.LogDebug("Matching request {RequestUrl} to API specs...", url); + + var matchingKey = apiSpecsByUrl.Keys.FirstOrDefault(url.StartsWith); + if (matchingKey is null) + { + Logger.LogDebug("No matching API spec found for {RequestUrl}", url); + unmatchedRequests.Add(request); + continue; + } + + if (!requestsByApiSpec.TryGetValue(apiSpecsByUrl[matchingKey], out var value)) + { + value = []; + requestsByApiSpec[apiSpecsByUrl[matchingKey]] = value; + } + + value.Add(request); + } + + return (requestsByApiSpec, unmatchedRequests); + } + + private static string GetApiName(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return "Unknown"; + } + + var uri = new Uri(url); + return uri.Authority; + } +} diff --git a/DevProxy.Plugins/Reporting/MinimalPermissionsPlugin.cs b/DevProxy.Plugins/Reporting/MinimalPermissionsPlugin.cs index b2e925456..89ab020a1 100644 --- a/DevProxy.Plugins/Reporting/MinimalPermissionsPlugin.cs +++ b/DevProxy.Plugins/Reporting/MinimalPermissionsPlugin.cs @@ -1,277 +1,277 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using DevProxy.Abstractions.Plugins; -using DevProxy.Abstractions.Proxy; -using DevProxy.Abstractions.Utils; -using DevProxy.Plugins.Models; -using DevProxy.Plugins.Utils; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.OpenApi; -using Microsoft.OpenApi.Reader; -using System.Text.RegularExpressions; - -namespace DevProxy.Plugins.Reporting; - -public sealed class MinimalPermissionsPluginConfiguration -{ - public string? ApiSpecsFolderPath { get; set; } - public string? SchemeName { get; set; } -} - -public sealed class MinimalPermissionsPlugin( - HttpClient httpClient, - ILogger logger, - ISet urlsToWatch, - IProxyConfiguration proxyConfiguration, - IConfigurationSection pluginConfigurationSection) : - BaseReportingPlugin( - httpClient, - logger, - urlsToWatch, - proxyConfiguration, - pluginConfigurationSection) -{ - private Dictionary? _apiSpecsByUrl; - - public override string Name => nameof(MinimalPermissionsPlugin); - - public override async Task InitializeAsync(InitArgs e, CancellationToken cancellationToken) - { - await base.InitializeAsync(e, cancellationToken); - - if (string.IsNullOrWhiteSpace(Configuration.ApiSpecsFolderPath)) - { - Enabled = false; - throw new InvalidOperationException("ApiSpecsFolderPath is required."); - } - - Configuration.ApiSpecsFolderPath = ProxyUtils.GetFullPath(Configuration.ApiSpecsFolderPath, ProxyConfiguration.ConfigFile); - if (!Path.Exists(Configuration.ApiSpecsFolderPath)) - { - Enabled = false; - throw new InvalidOperationException($"ApiSpecsFolderPath '{Configuration.ApiSpecsFolderPath}' does not exist."); - } - } - - public override async Task AfterRecordingStopAsync(RecordingArgs e, CancellationToken cancellationToken) - { - Logger.LogTrace("{Method} called", nameof(AfterRecordingStopAsync)); - - ArgumentNullException.ThrowIfNull(e); - - var interceptedRequests = e.RequestLogs - .Where(l => - l.MessageType == MessageType.InterceptedRequest && - !l.Message.StartsWith("OPTIONS", StringComparison.OrdinalIgnoreCase) && - l.Context?.Session is not null && - ProxyUtils.MatchesUrlToWatch(UrlsToWatch, l.Context.Session.Request.RequestUri.AbsoluteUri) - ); - if (!interceptedRequests.Any()) - { - Logger.LogRequest("No requests to process", MessageType.Skipped); - return; - } - - Logger.LogInformation("Checking if recorded API requests use minimal permissions as defined in API specs..."); - - _apiSpecsByUrl ??= await LoadApiSpecsAsync(Configuration.ApiSpecsFolderPath!, cancellationToken); - if (_apiSpecsByUrl is null || _apiSpecsByUrl.Count == 0) - { - Logger.LogWarning("No API definitions found in the specified folder."); - return; - } - - var (requestsByApiSpec, unmatchedApiSpecRequests) = GetRequestsByApiSpec(interceptedRequests, _apiSpecsByUrl); - - var errors = new List(); - var results = new List(); - var unmatchedRequests = new List( - unmatchedApiSpecRequests.Select(r => r.Message) - ); - - foreach (var (apiSpec, requests) in requestsByApiSpec) - { - var minimalPermissions = apiSpec.CheckMinimalPermissions(requests, Logger, Configuration.SchemeName); - - var result = new MinimalPermissionsPluginReportApiResult - { - ApiName = GetApiName(minimalPermissions.OperationsFromRequests.Any() ? - minimalPermissions.OperationsFromRequests.First().OriginalUrl : null), - Requests = [.. minimalPermissions.OperationsFromRequests - .Select(o => $"{o.Method} {o.OriginalUrl}") - .Distinct()], - TokenPermissions = [.. minimalPermissions.TokenPermissions.Distinct()], - MinimalPermissions = minimalPermissions.MinimalScopes, - SchemeName = Configuration.SchemeName - }; - results.Add(result); - - var unmatchedApiRequests = minimalPermissions.OperationsFromRequests - .Where(o => minimalPermissions.UnmatchedOperations.Contains($"{o.Method} {o.TokenizedUrl}")) - .Select(o => $"{o.Method} {o.OriginalUrl}"); - unmatchedRequests.AddRange(unmatchedApiRequests); - errors.AddRange(minimalPermissions.Errors); - - if (unmatchedApiRequests.Any()) - { - if (Logger.IsEnabled(LogLevel.Warning)) - { - Logger.LogWarning( - "Unmatched requests for API {ApiName}:{NewLine}- {UnmatchedRequests}", - result.ApiName, - Environment.NewLine, - string.Join($"{Environment.NewLine}- ", unmatchedApiRequests) - ); - } - } - - if (minimalPermissions.Errors.Any()) - { - if (Logger.IsEnabled(LogLevel.Warning)) - { - Logger.LogWarning( - "Errors for API {ApiName}:{NewLine}- {Errors}", - result.ApiName, - Environment.NewLine, - string.Join($"{Environment.NewLine}- ", minimalPermissions.Errors.Select(e => $"{e.Request}: {e.Error}")) - ); - } - } - - if (string.IsNullOrWhiteSpace(Configuration.SchemeName)) - { - if (Logger.IsEnabled(LogLevel.Information)) - { - Logger.LogInformation("Minimal permissions: {MinimalScopes}", string.Join(", ", result.MinimalPermissions)); - } - } - else - { - if (Logger.IsEnabled(LogLevel.Information)) - { - Logger.LogInformation("Minimal permissions of '{SchemeName}' scheme: {MinimalScopes}", - Configuration.SchemeName, string.Join(", ", result.MinimalPermissions)); - } - } - } - - var report = new MinimalPermissionsPluginReport() - { - Results = [.. results], - UnmatchedRequests = [.. unmatchedRequests], - Errors = [.. errors] - }; - - StoreReport(report, e); - - Logger.LogTrace("Left {Name}", nameof(AfterRecordingStopAsync)); - } - - private async Task> LoadApiSpecsAsync(string apiSpecsFolderPath, CancellationToken cancellationToken) - { - var apiDefinitions = new Dictionary(); - foreach (var file in Directory.EnumerateFiles(apiSpecsFolderPath, "*.*", SearchOption.AllDirectories)) - { - cancellationToken.ThrowIfCancellationRequested(); - - var extension = Path.GetExtension(file); - if (!extension.Equals(".json", StringComparison.OrdinalIgnoreCase) && - !extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase) && - !extension.Equals(".yml", StringComparison.OrdinalIgnoreCase)) - { - Logger.LogDebug("Skipping file '{File}' because it is not a JSON or YAML file", file); - continue; - } - - Logger.LogDebug("Processing file '{File}'...", file); - try - { - var fileContents = await File.ReadAllTextAsync(file, cancellationToken); - fileContents = MinimalPermissionsUtils.ResolveApiSpecVariables(fileContents, file, ProxyConfiguration.Env, Logger); - - var settings = new OpenApiReaderSettings(); - settings.AddYamlReader(); - var readResult = OpenApiDocument.Parse(fileContents, null, settings); - var apiDefinition = readResult.Document; - if (apiDefinition is null) - { - continue; - } - if (apiDefinition.Servers is null || apiDefinition.Servers.Count == 0) - { - Logger.LogDebug("No servers found in API definition file '{File}'", file); - continue; - } - foreach (var server in apiDefinition.Servers) - { - if (server.Url is null) - { - Logger.LogDebug("No URL found for server '{Server}'", server.Description ?? "unnamed"); - continue; - } - - Logger.LogDebug("Found server '{Server}' with URL '{Url}'", server.Description ?? "unnamed", server.Url); - - var serverUrl = server.Url; - if (server.Url.Contains('{', StringComparison.Ordinal)) - { - serverUrl = ProxyUtils.UrlWithParametersToRegex(server.Url); - Logger.LogDebug("Transformed server URL '{OriginalUrl}' to '{TransformedUrl}'", server.Url, serverUrl); - } - - apiDefinitions[serverUrl] = apiDefinition; - } - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to load API definition from file '{File}'", file); - } - } - return apiDefinitions; - } - - private (Dictionary> RequestsByApiSpec, IEnumerable UnmatchedRequests) GetRequestsByApiSpec(IEnumerable interceptedRequests, Dictionary apiSpecsByUrl) - { - var unmatchedRequests = new List(); - var requestsByApiSpec = new Dictionary>(); - foreach (var request in interceptedRequests) - { - var url = request.Message.Split(' ')[1]; - Logger.LogDebug("Matching request {RequestUrl} to API specs...", url); - - var matchingKey = apiSpecsByUrl.Keys.FirstOrDefault(urlOrPattern => - url.StartsWith(urlOrPattern, StringComparison.OrdinalIgnoreCase) || - Regex.IsMatch(url, urlOrPattern)); - if (matchingKey is null) - { - Logger.LogDebug("No matching API spec found for {RequestUrl}", url); - unmatchedRequests.Add(request); - continue; - } - - if (!requestsByApiSpec.TryGetValue(apiSpecsByUrl[matchingKey], out var value)) - { - value = []; - requestsByApiSpec[apiSpecsByUrl[matchingKey]] = value; - } - - value.Add(request); - } - - return (requestsByApiSpec, unmatchedRequests); - } - - private static string GetApiName(string? url) - { - if (string.IsNullOrWhiteSpace(url)) - { - return "Unknown"; - } - - var uri = new Uri(url); - return uri.Authority; - } -} +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Plugins; +using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Utils; +using DevProxy.Plugins.Models; +using DevProxy.Plugins.Utils; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; +using System.Text.RegularExpressions; + +namespace DevProxy.Plugins.Reporting; + +public sealed class MinimalPermissionsPluginConfiguration +{ + public string? ApiSpecsFolderPath { get; set; } + public string? SchemeName { get; set; } +} + +public sealed class MinimalPermissionsPlugin( + HttpClient httpClient, + ILogger logger, + ISet urlsToWatch, + IProxyConfiguration proxyConfiguration, + IConfigurationSection pluginConfigurationSection) : + BaseReportingPlugin( + httpClient, + logger, + urlsToWatch, + proxyConfiguration, + pluginConfigurationSection) +{ + private Dictionary? _apiSpecsByUrl; + + public override string Name => nameof(MinimalPermissionsPlugin); + + public override async Task InitializeAsync(InitArgs e, CancellationToken cancellationToken) + { + await base.InitializeAsync(e, cancellationToken); + + if (string.IsNullOrWhiteSpace(Configuration.ApiSpecsFolderPath)) + { + Enabled = false; + throw new InvalidOperationException("ApiSpecsFolderPath is required."); + } + + Configuration.ApiSpecsFolderPath = ProxyUtils.GetFullPath(Configuration.ApiSpecsFolderPath, ProxyConfiguration.ConfigFile); + if (!Path.Exists(Configuration.ApiSpecsFolderPath)) + { + Enabled = false; + throw new InvalidOperationException($"ApiSpecsFolderPath '{Configuration.ApiSpecsFolderPath}' does not exist."); + } + } + + public override async Task AfterRecordingStopAsync(RecordingArgs e, CancellationToken cancellationToken) + { + Logger.LogTrace("{Method} called", nameof(AfterRecordingStopAsync)); + + ArgumentNullException.ThrowIfNull(e); + + var interceptedRequests = e.RequestLogs + .Where(l => + l.MessageType == MessageType.InterceptedRequest && + !l.Message.StartsWith("OPTIONS", StringComparison.OrdinalIgnoreCase) && + l.Context?.Session is not null && + ProxyUtils.MatchesUrlToWatch(UrlsToWatch, l.Context.Session.Request.RequestUri.AbsoluteUri) + ); + if (!interceptedRequests.Any()) + { + Logger.LogRequest("No requests to process", MessageType.Skipped); + return; + } + + Logger.LogInformation("Checking if recorded API requests use minimal permissions as defined in API specs..."); + + _apiSpecsByUrl ??= await LoadApiSpecsAsync(Configuration.ApiSpecsFolderPath!, cancellationToken); + if (_apiSpecsByUrl is null || _apiSpecsByUrl.Count == 0) + { + Logger.LogWarning("No API definitions found in the specified folder."); + return; + } + + var (requestsByApiSpec, unmatchedApiSpecRequests) = GetRequestsByApiSpec(interceptedRequests, _apiSpecsByUrl); + + var errors = new List(); + var results = new List(); + var unmatchedRequests = new List( + unmatchedApiSpecRequests.Select(r => r.Message) + ); + + foreach (var (apiSpec, requests) in requestsByApiSpec) + { + var minimalPermissions = apiSpec.CheckMinimalPermissions(requests, Logger, Configuration.SchemeName); + + var result = new MinimalPermissionsPluginReportApiResult + { + ApiName = GetApiName(minimalPermissions.OperationsFromRequests.Any() ? + minimalPermissions.OperationsFromRequests.First().OriginalUrl : null), + Requests = [.. minimalPermissions.OperationsFromRequests + .Select(o => $"{o.Method} {o.OriginalUrl}") + .Distinct()], + TokenPermissions = [.. minimalPermissions.TokenPermissions.Distinct()], + MinimalPermissions = minimalPermissions.MinimalScopes, + SchemeName = Configuration.SchemeName + }; + results.Add(result); + + var unmatchedApiRequests = minimalPermissions.OperationsFromRequests + .Where(o => minimalPermissions.UnmatchedOperations.Contains($"{o.Method} {o.TokenizedUrl}")) + .Select(o => $"{o.Method} {o.OriginalUrl}"); + unmatchedRequests.AddRange(unmatchedApiRequests); + errors.AddRange(minimalPermissions.Errors); + + if (unmatchedApiRequests.Any()) + { + if (Logger.IsEnabled(LogLevel.Warning)) + { + Logger.LogWarning( + "Unmatched requests for API {ApiName}:{NewLine}- {UnmatchedRequests}", + result.ApiName, + Environment.NewLine, + string.Join($"{Environment.NewLine}- ", unmatchedApiRequests) + ); + } + } + + if (minimalPermissions.Errors.Any()) + { + if (Logger.IsEnabled(LogLevel.Warning)) + { + Logger.LogWarning( + "Errors for API {ApiName}:{NewLine}- {Errors}", + result.ApiName, + Environment.NewLine, + string.Join($"{Environment.NewLine}- ", minimalPermissions.Errors.Select(e => $"{e.Request}: {e.Error}")) + ); + } + } + + if (string.IsNullOrWhiteSpace(Configuration.SchemeName)) + { + if (Logger.IsEnabled(LogLevel.Information)) + { + Logger.LogInformation("Minimal permissions: {MinimalScopes}", string.Join(", ", result.MinimalPermissions)); + } + } + else + { + if (Logger.IsEnabled(LogLevel.Information)) + { + Logger.LogInformation("Minimal permissions of '{SchemeName}' scheme: {MinimalScopes}", + Configuration.SchemeName, string.Join(", ", result.MinimalPermissions)); + } + } + } + + var report = new MinimalPermissionsPluginReport() + { + Results = [.. results], + UnmatchedRequests = [.. unmatchedRequests], + Errors = [.. errors] + }; + + StoreReport(report, e); + + Logger.LogTrace("Left {Name}", nameof(AfterRecordingStopAsync)); + } + + private async Task> LoadApiSpecsAsync(string apiSpecsFolderPath, CancellationToken cancellationToken) + { + var apiDefinitions = new Dictionary(); + foreach (var file in Directory.EnumerateFiles(apiSpecsFolderPath, "*.*", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var extension = Path.GetExtension(file); + if (!extension.Equals(".json", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".yml", StringComparison.OrdinalIgnoreCase)) + { + Logger.LogDebug("Skipping file '{File}' because it is not a JSON or YAML file", file); + continue; + } + + Logger.LogDebug("Processing file '{File}'...", file); + try + { + var fileContents = await File.ReadAllTextAsync(file, cancellationToken); + fileContents = MinimalPermissionsUtils.ResolveApiSpecVariables(fileContents, file, ProxyConfiguration.Env, Logger); + + var settings = new OpenApiReaderSettings(); + settings.AddYamlReader(); + var readResult = OpenApiDocument.Parse(fileContents, null, settings); + var apiDefinition = readResult.Document; + if (apiDefinition is null) + { + continue; + } + if (apiDefinition.Servers is null || apiDefinition.Servers.Count == 0) + { + Logger.LogDebug("No servers found in API definition file '{File}'", file); + continue; + } + foreach (var server in apiDefinition.Servers) + { + if (server.Url is null) + { + Logger.LogDebug("No URL found for server '{Server}'", server.Description ?? "unnamed"); + continue; + } + + Logger.LogDebug("Found server '{Server}' with URL '{Url}'", server.Description ?? "unnamed", server.Url); + + var serverUrl = server.Url; + if (server.Url.Contains('{', StringComparison.Ordinal)) + { + serverUrl = ProxyUtils.UrlWithParametersToRegex(server.Url); + Logger.LogDebug("Transformed server URL '{OriginalUrl}' to '{TransformedUrl}'", server.Url, serverUrl); + } + + apiDefinitions[serverUrl] = apiDefinition; + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to load API definition from file '{File}'", file); + } + } + return apiDefinitions; + } + + private (Dictionary> RequestsByApiSpec, IEnumerable UnmatchedRequests) GetRequestsByApiSpec(IEnumerable interceptedRequests, Dictionary apiSpecsByUrl) + { + var unmatchedRequests = new List(); + var requestsByApiSpec = new Dictionary>(); + foreach (var request in interceptedRequests) + { + var url = request.Message.Split(' ')[1]; + Logger.LogDebug("Matching request {RequestUrl} to API specs...", url); + + var matchingKey = apiSpecsByUrl.Keys.FirstOrDefault(urlOrPattern => + url.StartsWith(urlOrPattern, StringComparison.OrdinalIgnoreCase) || + Regex.IsMatch(url, urlOrPattern)); + if (matchingKey is null) + { + Logger.LogDebug("No matching API spec found for {RequestUrl}", url); + unmatchedRequests.Add(request); + continue; + } + + if (!requestsByApiSpec.TryGetValue(apiSpecsByUrl[matchingKey], out var value)) + { + value = []; + requestsByApiSpec[apiSpecsByUrl[matchingKey]] = value; + } + + value.Add(request); + } + + return (requestsByApiSpec, unmatchedRequests); + } + + private static string GetApiName(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return "Unknown"; + } + + var uri = new Uri(url); + return uri.Authority; + } +} diff --git a/DevProxy.Plugins/Utils/MinimalPermissionsUtils.cs b/DevProxy.Plugins/Utils/MinimalPermissionsUtils.cs index 0c2a0cfdd..af26ca9fa 100644 --- a/DevProxy.Plugins/Utils/MinimalPermissionsUtils.cs +++ b/DevProxy.Plugins/Utils/MinimalPermissionsUtils.cs @@ -1,139 +1,139 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using DevProxy.Abstractions.Utils; -using Microsoft.Extensions.Logging; -using System.IdentityModel.Tokens.Jwt; -using System.Text.Json; -using YamlDotNet.Core; - -namespace DevProxy.Plugins.Utils; - -static class MinimalPermissionsUtils -{ - /// - /// Resolves environment variable placeholders (${NAME}) in an API - /// specification and the deprecated {NAME} variables passed with --env. - /// - public static string ResolveApiSpecVariables(string fileContents, string filePath, Dictionary legacyVariables, ILogger logger) - { - var unresolvedVariables = new List(); - var usesLegacyVariables = false; - - // legacy {NAME} variables are replaced only in text outside of ${NAME} placeholders - // so that escaped placeholders and resolved values aren't changed - string replaceLegacyVariables(string text) - { - var replaced = ProxyUtils.ReplaceVariables(text, legacyVariables, v => $"{{{v}}}"); - usesLegacyVariables |= !string.Equals(text, replaced, StringComparison.Ordinal); - return replaced; - } - Func? transformText = legacyVariables.Count > 0 ? replaceLegacyVariables : null; - - string resolved; - try - { - // resolve values in the parsed document so that values with quotes don't break it - // and keys aren't changed - if (Path.GetExtension(filePath).Equals(".json", StringComparison.OrdinalIgnoreCase)) - { - resolved = EnvironmentVariablePlaceholders.ResolveInJson(fileContents, Environment.GetEnvironmentVariable, unresolvedVariables, transformText); - } - else - { - // unquoted legacy values like `url: {NAME}` parse as YAML flow mappings, - // so replace legacy variables in the raw text first, like before - var withLegacyVariables = transformText is null - ? fileContents - : EnvironmentVariablePlaceholders.TransformOutsidePlaceholders(fileContents, transformText); - resolved = EnvironmentVariablePlaceholders.ResolveInYaml(withLegacyVariables, Environment.GetEnvironmentVariable, unresolvedVariables); - } - } - catch (Exception ex) when (ex is JsonException or YamlException) - { - unresolvedVariables.Clear(); - usesLegacyVariables = false; - resolved = EnvironmentVariablePlaceholders.Resolve(fileContents, Environment.GetEnvironmentVariable, unresolvedVariables, transformText); - } - EnvironmentVariablePlaceholders.LogUnresolvedVariables(logger, unresolvedVariables, filePath); - - if (usesLegacyVariables) - { - logger.LogWarning( - "Referencing variables using {{NAME}} in {File} is deprecated and will be removed in a future version. Use ${{NAME}} instead", - filePath); - } - - return resolved; - } - - /// - /// Gets the scopes from the JWT token. - /// - /// The JWT token including the 'Bearer' prefix. - /// The scopes from the JWT token or empty array if no scopes found or error occurred. - public static string[] GetScopesFromToken(string? jwtToken, ILogger logger) - { - logger.LogDebug("Getting scopes from JWT token..."); - - if (string.IsNullOrEmpty(jwtToken)) - { - return []; - } - - try - { - var token = jwtToken.Split(' ')[1]; - var handler = new JwtSecurityTokenHandler(); - var jsonToken = handler.ReadToken(token) as JwtSecurityToken; - var scopes = jsonToken?.Claims - .Where(c => c.Type == "scp") - .Select(c => c.Value) - .ToArray() ?? []; - - if (logger.IsEnabled(LogLevel.Debug)) - { - logger.LogDebug("Scopes found in the token: {Scopes}", string.Join(", ", scopes)); - } - return scopes; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to parse JWT token"); - return []; - } - } - - public static (IEnumerable minimalScopes, IEnumerable unmatchedOperations) GetMinimalScopes(IEnumerable requests, Dictionary operationsAndScopes) - { - var unmatchedOperations = requests - .Where(o => !operationsAndScopes.Keys.Contains(o, StringComparer.OrdinalIgnoreCase)); - - var minimalScopesPerOperation = operationsAndScopes - .Where(o => requests.Contains(o.Key, StringComparer.OrdinalIgnoreCase)) - .Select(o => new KeyValuePair(o.Key, o.Value.First())) - .ToDictionary(); - - // for each minimal scope check if it overrules any other minimal scope - // (position > 0, because the minimal scope is always first). if it does, - // replace the minimal scope with the overruling scope - foreach (var scope in minimalScopesPerOperation.Values) - { - foreach (var minimalScope in minimalScopesPerOperation) - { - if (Array.IndexOf(operationsAndScopes[minimalScope.Key], scope) > 0) - { - minimalScopesPerOperation[minimalScope.Key] = scope; - } - } - } - - return ( - minimalScopesPerOperation - .Select(s => s.Value) - .Distinct(), - unmatchedOperations - ); - } +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Utils; +using Microsoft.Extensions.Logging; +using System.IdentityModel.Tokens.Jwt; +using System.Text.Json; +using YamlDotNet.Core; + +namespace DevProxy.Plugins.Utils; + +static class MinimalPermissionsUtils +{ + /// + /// Resolves environment variable placeholders (${NAME}) in an API + /// specification and the deprecated {NAME} variables passed with --env. + /// + public static string ResolveApiSpecVariables(string fileContents, string filePath, Dictionary legacyVariables, ILogger logger) + { + var unresolvedVariables = new List(); + var usesLegacyVariables = false; + + // legacy {NAME} variables are replaced only in text outside of ${NAME} placeholders + // so that escaped placeholders and resolved values aren't changed + string replaceLegacyVariables(string text) + { + var replaced = ProxyUtils.ReplaceVariables(text, legacyVariables, v => $"{{{v}}}"); + usesLegacyVariables |= !string.Equals(text, replaced, StringComparison.Ordinal); + return replaced; + } + Func? transformText = legacyVariables.Count > 0 ? replaceLegacyVariables : null; + + string resolved; + try + { + // resolve values in the parsed document so that values with quotes don't break it + // and keys aren't changed + if (Path.GetExtension(filePath).Equals(".json", StringComparison.OrdinalIgnoreCase)) + { + resolved = EnvironmentVariablePlaceholders.ResolveInJson(fileContents, Environment.GetEnvironmentVariable, unresolvedVariables, transformText); + } + else + { + // unquoted legacy values like `url: {NAME}` parse as YAML flow mappings, + // so replace legacy variables in the raw text first, like before + var withLegacyVariables = transformText is null + ? fileContents + : EnvironmentVariablePlaceholders.TransformOutsidePlaceholders(fileContents, transformText); + resolved = EnvironmentVariablePlaceholders.ResolveInYaml(withLegacyVariables, Environment.GetEnvironmentVariable, unresolvedVariables); + } + } + catch (Exception ex) when (ex is JsonException or YamlException) + { + unresolvedVariables.Clear(); + usesLegacyVariables = false; + resolved = EnvironmentVariablePlaceholders.Resolve(fileContents, Environment.GetEnvironmentVariable, unresolvedVariables, transformText); + } + EnvironmentVariablePlaceholders.LogUnresolvedVariables(logger, unresolvedVariables, filePath); + + if (usesLegacyVariables) + { + logger.LogWarning( + "Referencing variables using {{NAME}} in {File} is deprecated and will be removed in a future version. Use ${{NAME}} instead", + filePath); + } + + return resolved; + } + + /// + /// Gets the scopes from the JWT token. + /// + /// The JWT token including the 'Bearer' prefix. + /// The scopes from the JWT token or empty array if no scopes found or error occurred. + public static string[] GetScopesFromToken(string? jwtToken, ILogger logger) + { + logger.LogDebug("Getting scopes from JWT token..."); + + if (string.IsNullOrEmpty(jwtToken)) + { + return []; + } + + try + { + var token = jwtToken.Split(' ')[1]; + var handler = new JwtSecurityTokenHandler(); + var jsonToken = handler.ReadToken(token) as JwtSecurityToken; + var scopes = jsonToken?.Claims + .Where(c => c.Type == "scp") + .Select(c => c.Value) + .ToArray() ?? []; + + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("Scopes found in the token: {Scopes}", string.Join(", ", scopes)); + } + return scopes; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to parse JWT token"); + return []; + } + } + + public static (IEnumerable minimalScopes, IEnumerable unmatchedOperations) GetMinimalScopes(IEnumerable requests, Dictionary operationsAndScopes) + { + var unmatchedOperations = requests + .Where(o => !operationsAndScopes.Keys.Contains(o, StringComparer.OrdinalIgnoreCase)); + + var minimalScopesPerOperation = operationsAndScopes + .Where(o => requests.Contains(o.Key, StringComparer.OrdinalIgnoreCase)) + .Select(o => new KeyValuePair(o.Key, o.Value.First())) + .ToDictionary(); + + // for each minimal scope check if it overrules any other minimal scope + // (position > 0, because the minimal scope is always first). if it does, + // replace the minimal scope with the overruling scope + foreach (var scope in minimalScopesPerOperation.Values) + { + foreach (var minimalScope in minimalScopesPerOperation) + { + if (Array.IndexOf(operationsAndScopes[minimalScope.Key], scope) > 0) + { + minimalScopesPerOperation[minimalScope.Key] = scope; + } + } + } + + return ( + minimalScopesPerOperation + .Select(s => s.Value) + .Distinct(), + unmatchedOperations + ); + } } \ No newline at end of file diff --git a/DevProxy.Tests/BaseLoaderPlaceholderTests.cs b/DevProxy.Tests/BaseLoaderPlaceholderTests.cs index f83e47026..48c5482a5 100644 --- a/DevProxy.Tests/BaseLoaderPlaceholderTests.cs +++ b/DevProxy.Tests/BaseLoaderPlaceholderTests.cs @@ -1,72 +1,72 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using DevProxy.Abstractions.Plugins; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using System.Text.Json; -using Xunit; - -namespace DevProxy.Tests; - -public sealed class BaseLoaderPlaceholderTests : IDisposable -{ - private const string Variable = "DEVPROXY_TEST_LOADER_PLACEHOLDER"; - private readonly string _folder = Directory.CreateTempSubdirectory("devproxy-loader").FullName; - - public BaseLoaderPlaceholderTests() - { - Environment.SetEnvironmentVariable(Variable, "resolved \"value\""); - } - - public void Dispose() - { - Environment.SetEnvironmentVariable(Variable, null); - Directory.Delete(_folder, true); - } - - [Theory] - [InlineData("mocks.json", $$"""{ "body": "${{{Variable}}}", "missing": "${DEVPROXY_TEST_LOADER_MISSING}" }""")] - [InlineData("mocks.yaml", $$""" - body: ${{{Variable}}} - missing: ${DEVPROXY_TEST_LOADER_MISSING} - """)] - public async Task LoadsFileWithResolvedPlaceholders(string fileName, string contents) - { - var loader = await LoadAsync(fileName, contents, resolve: true); - - using var doc = JsonDocument.Parse(loader.Contents!); - Assert.Equal("resolved \"value\"", doc.RootElement.GetProperty("body").GetString()); - Assert.Equal("${DEVPROXY_TEST_LOADER_MISSING}", doc.RootElement.GetProperty("missing").GetString()); - } - - [Fact] - public async Task LeavesPlaceholdersWhenResolutionIsDisabled() - { - var loader = await LoadAsync("rewrites.json", $$"""{ "url": "${{{Variable}}}" }""", resolve: false); - - using var doc = JsonDocument.Parse(loader.Contents!); - Assert.Equal($"${{{Variable}}}", doc.RootElement.GetProperty("url").GetString()); - } - - private async Task LoadAsync(string fileName, string contents, bool resolve) - { - var path = Path.Combine(_folder, fileName); - await File.WriteAllTextAsync(path, contents); - using var httpClient = new HttpClient(); - var loader = new TestLoader(httpClient, NullLogger.Instance, path, resolve); - await loader.InitFileWatcherAsync(CancellationToken.None); - loader.Dispose(); - return loader; - } - - private sealed class TestLoader(HttpClient httpClient, ILogger logger, string filePath, bool resolve) : - BaseLoader(httpClient, logger, new FakeProxyConfiguration()) - { - public string? Contents { get; private set; } - protected override string FilePath => filePath; - protected override bool ResolveEnvironmentVariables => resolve; - protected override void LoadData(string fileContents) => Contents = fileContents; - } -} +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Plugins; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System.Text.Json; +using Xunit; + +namespace DevProxy.Tests; + +public sealed class BaseLoaderPlaceholderTests : IDisposable +{ + private const string Variable = "DEVPROXY_TEST_LOADER_PLACEHOLDER"; + private readonly string _folder = Directory.CreateTempSubdirectory("devproxy-loader").FullName; + + public BaseLoaderPlaceholderTests() + { + Environment.SetEnvironmentVariable(Variable, "resolved \"value\""); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(Variable, null); + Directory.Delete(_folder, true); + } + + [Theory] + [InlineData("mocks.json", $$"""{ "body": "${{{Variable}}}", "missing": "${DEVPROXY_TEST_LOADER_MISSING}" }""")] + [InlineData("mocks.yaml", $$""" + body: ${{{Variable}}} + missing: ${DEVPROXY_TEST_LOADER_MISSING} + """)] + public async Task LoadsFileWithResolvedPlaceholders(string fileName, string contents) + { + var loader = await LoadAsync(fileName, contents, resolve: true); + + using var doc = JsonDocument.Parse(loader.Contents!); + Assert.Equal("resolved \"value\"", doc.RootElement.GetProperty("body").GetString()); + Assert.Equal("${DEVPROXY_TEST_LOADER_MISSING}", doc.RootElement.GetProperty("missing").GetString()); + } + + [Fact] + public async Task LeavesPlaceholdersWhenResolutionIsDisabled() + { + var loader = await LoadAsync("rewrites.json", $$"""{ "url": "${{{Variable}}}" }""", resolve: false); + + using var doc = JsonDocument.Parse(loader.Contents!); + Assert.Equal($"${{{Variable}}}", doc.RootElement.GetProperty("url").GetString()); + } + + private async Task LoadAsync(string fileName, string contents, bool resolve) + { + var path = Path.Combine(_folder, fileName); + await File.WriteAllTextAsync(path, contents); + using var httpClient = new HttpClient(); + var loader = new TestLoader(httpClient, NullLogger.Instance, path, resolve); + await loader.InitFileWatcherAsync(CancellationToken.None); + loader.Dispose(); + return loader; + } + + private sealed class TestLoader(HttpClient httpClient, ILogger logger, string filePath, bool resolve) : + BaseLoader(httpClient, logger, new FakeProxyConfiguration()) + { + public string? Contents { get; private set; } + protected override string FilePath => filePath; + protected override bool ResolveEnvironmentVariables => resolve; + protected override void LoadData(string fileContents) => Contents = fileContents; + } +} diff --git a/DevProxy/Extensions/ConfigurationManagerExtensions.cs b/DevProxy/Extensions/ConfigurationManagerExtensions.cs index 65bf9e8e2..d9a9392cc 100644 --- a/DevProxy/Extensions/ConfigurationManagerExtensions.cs +++ b/DevProxy/Extensions/ConfigurationManagerExtensions.cs @@ -1,30 +1,30 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using DevProxy.Abstractions.Utils; -using DevProxy.Commands; - -#pragma warning disable IDE0130 -namespace Microsoft.Extensions.Configuration; -#pragma warning restore IDE0130 - -static class ConfigurationManagerExtensions -{ - public static ConfigurationManager ConfigureDevProxyConfig(this ConfigurationManager configuration, DevProxyConfigOptions options) - { - configuration.Sources.Clear(); - _ = configuration.SetBasePath(Directory.GetCurrentDirectory()); - - foreach (var configFile in ProxyUtils.GetConfigFileCandidates(options.ConfigFile)) - { - if (!string.IsNullOrEmpty(configFile) && File.Exists(configFile)) - { - _ = configuration.AddConfigFile(configFile, optional: false, reloadOnChange: true, environmentVariables: options.Env); - return configuration; - } - } - - throw new InvalidOperationException("No configuration file found. Please create a devproxyrc.json or devproxyrc.yaml file in the current directory."); - } +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Utils; +using DevProxy.Commands; + +#pragma warning disable IDE0130 +namespace Microsoft.Extensions.Configuration; +#pragma warning restore IDE0130 + +static class ConfigurationManagerExtensions +{ + public static ConfigurationManager ConfigureDevProxyConfig(this ConfigurationManager configuration, DevProxyConfigOptions options) + { + configuration.Sources.Clear(); + _ = configuration.SetBasePath(Directory.GetCurrentDirectory()); + + foreach (var configFile in ProxyUtils.GetConfigFileCandidates(options.ConfigFile)) + { + if (!string.IsNullOrEmpty(configFile) && File.Exists(configFile)) + { + _ = configuration.AddConfigFile(configFile, optional: false, reloadOnChange: true, environmentVariables: options.Env); + return configuration; + } + } + + throw new InvalidOperationException("No configuration file found. Please create a devproxyrc.json or devproxyrc.yaml file in the current directory."); + } } \ No newline at end of file diff --git a/DevProxy/Program.cs b/DevProxy/Program.cs index 6d3dcc5b6..47e4cad1c 100644 --- a/DevProxy/Program.cs +++ b/DevProxy/Program.cs @@ -1,479 +1,479 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using DevProxy; -using DevProxy.Abstractions.Utils; -using DevProxy.Commands; -using DevProxy.Proxy; -using DevProxy.State; -using System.Diagnostics; -using System.Globalization; -using System.Net; -using System.Runtime.InteropServices; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; - -// Keep management files discoverable even when env overrides change user data paths. -_ = StateManager.GetConfigFolder(); - -// Handle detached mode - spawn a child process and exit -// Only applies to root command (starting the proxy), not subcommands -if (DevProxyCommand.IsRootCommand && - DevProxyCommand.IsDetachedMode && - !DevProxyCommand.IsInternalDaemon) -{ - var detachedExitCode = await StartDetachedProcessAsync(args); - Environment.Exit(detachedExitCode); - return; -} - -// For daemon mode, redirect Console.Out/Error to the log file -// so that the normal console formatters (ProxyConsoleFormatter, -// MachineConsoleFormatter) write to the file instead of stdout. -// Console.IsOutputRedirected becomes true, which automatically -// strips ANSI color codes from human-readable output. -StreamWriter? _detachedLogWriter = null; -if (DevProxyCommand.IsInternalDaemon) -{ - var logFilePath = DevProxyCommand.DetachedLogFilePath; - var logDir = Path.GetDirectoryName(logFilePath); - if (!string.IsNullOrEmpty(logDir)) - { - PrivateFiles.EnsureDirectory(StateManager.GetConfigFolder()); - PrivateFiles.EnsureDirectory(logDir); - } - -#pragma warning disable CA2000 // Lifetime managed manually; disposed before Environment.Exit - _detachedLogWriter = new StreamWriter(logFilePath, append: false, Encoding.UTF8) { AutoFlush = true }; -#pragma warning restore CA2000 - Console.SetOut(_detachedLogWriter); - Console.SetError(_detachedLogWriter); -} - -static async Task StartDetachedProcessAsync(string[] args) -{ - var isJsonOutput = IsJsonOutputRequested(args); - - // Recover from a system-proxy registration left behind by a previous - // instance that crashed before restoring the OS proxy. This must run before - // any state-loading call below, which prunes (deletes) the stale state files - // it depends on without restoring the proxy. - var reconciliation = await SystemProxyManager.ReconcileOrphanedSystemProxiesAsync(); - foreach (var orphan in reconciliation.Orphans) - { - var message = reconciliation.SystemProxyDisabled - ? $"Recovered system proxy left by a crashed Dev Proxy instance (PID: {orphan.Pid})." - : $"Removed stale system-proxy registration left by a crashed Dev Proxy instance (PID: {orphan.Pid}) while keeping the current system proxy owner unchanged."; - if (isJsonOutput) - { - await Console.Out.WriteLineAsync(FormatJsonLogEntry("info", message)); - } - else - { - await Console.Out.WriteLineAsync(message); - } - } - - // Check if an instance is already running as system proxy - var systemProxyInstance = await StateManager.FindSystemProxyInstanceAsync(); - if (systemProxyInstance is not null) - { - if (isJsonOutput) - { - await Console.Error.WriteLineAsync( - FormatJsonLogEntry("error", $"Dev Proxy is already running as system proxy (PID: {systemProxyInstance.Pid}). Use 'devproxy stop' to stop it first.")); - } - else - { - await Console.Error.WriteLineAsync($"Dev Proxy is already running as system proxy (PID: {systemProxyInstance.Pid})."); - await Console.Error.WriteLineAsync("Use 'devproxy stop' to stop it first."); - } - return 1; - } - - // Check for port conflicts with existing instances - var existingStates = await StateManager.LoadAllStatesAsync(); - if (existingStates.Count > 0) - { - var earlyOptions = new DevProxyConfigOptions(); - earlyOptions.ParseOptions(args); - var requestedPort = earlyOptions.Port ?? 8000; - var requestedApiPort = earlyOptions.ApiPort ?? 8897; - - foreach (var existing in existingStates) - { - if (requestedPort != 0 && existing.Port == requestedPort) - { - await Console.Error.WriteLineAsync($"Port {requestedPort} is already in use by Dev Proxy instance (PID: {existing.Pid})."); - await Console.Error.WriteLineAsync("Use a different --port or stop the existing instance first."); - return 1; - } - - if (requestedApiPort != 0 && existing.ApiUrl.EndsWith($":{requestedApiPort}", StringComparison.Ordinal)) - { - await Console.Error.WriteLineAsync($"API port {requestedApiPort} is already in use by Dev Proxy instance (PID: {existing.Pid})."); - await Console.Error.WriteLineAsync("Use a different --api-port or stop the existing instance first."); - return 1; - } - } - } - - // Clean up old log files - StateManager.CleanupOldLogs(); - - // Build the arguments for the daemon process - // Replace --detach/-d with --_internal-daemon - var daemonArgs = args - .Where(a => a is not "--detach" and not "-d") - .Append("--_internal-daemon") - .ToArray(); - - var executablePath = Environment.ProcessPath; - if (string.IsNullOrEmpty(executablePath)) - { - if (isJsonOutput) - { - await Console.Error.WriteLineAsync( - FormatJsonLogEntry("error", "Could not determine executable path.")); - } - else - { - await Console.Error.WriteLineAsync("Could not determine executable path."); - } - return 1; - } - - var startInfo = new ProcessStartInfo - { - FileName = executablePath, - Arguments = string.Join(" ", daemonArgs.Select(EscapeArgument)), - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - WorkingDirectory = Directory.GetCurrentDirectory() - }; - - // On Windows, we need to detach from the console - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - startInfo.WindowStyle = ProcessWindowStyle.Hidden; - } - - try - { - var process = Process.Start(startInfo); - if (process == null) - { - if (isJsonOutput) - { - await Console.Error.WriteLineAsync( - FormatJsonLogEntry("error", "Failed to start Dev Proxy process.")); - } - else - { - await Console.Error.WriteLineAsync("Failed to start Dev Proxy process."); - } - return 1; - } - - // Wait a moment for the proxy to start and write its state file - var timeout = TimeSpan.FromSeconds(10); - var stopwatch = Stopwatch.StartNew(); - - while (stopwatch.Elapsed < timeout) - { - await Task.Delay(200); - - var state = await StateManager.LoadStateByPidAsync(process.Id); - if (state is { Port: > 0 } && !string.IsNullOrEmpty(state.ApiUrl) && !state.ApiUrl.EndsWith(":0", StringComparison.Ordinal)) - { - var proxyUrl = state.ProxyUrl; - var token = ApiSecurity.ShouldDisplayTokenFor(state) ? - (await File.ReadAllTextAsync(ApiSecurity.GetTokenFilePath(state.Pid))).Trim() : null; - - if (isJsonOutput) - { - await Console.Out.WriteLineAsync(FormatJsonResultEntry(new - { - state.Pid, - ProxyUrl = proxyUrl, - state.ApiUrl, - Token = token, - state.LogFile - })); - } - else - { - var showToken = token is not null; - await Console.Out.WriteLineAsync("Dev Proxy started in background."); - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync($" PID: {state.Pid}"); - await Console.Out.WriteLineAsync($" Proxy URL: {proxyUrl}"); - await Console.Out.WriteLineAsync($" API URL: {state.ApiUrl}"); - if (showToken) - { - await Console.Out.WriteLineAsync($" API token: {token}"); - } - await Console.Out.WriteLineAsync($" Log file: {state.LogFile}"); - await Console.Out.WriteLineAsync(); - if (!showToken) - { - await Console.Out.WriteLineAsync($"Use 'devproxy api token --pid {state.Pid}' to get the API token."); - } - await Console.Out.WriteLineAsync("Use 'devproxy status' to check status."); - await Console.Out.WriteLineAsync("Use 'devproxy logs' to view logs."); - await Console.Out.WriteLineAsync("Use 'devproxy stop' to stop."); - } - return 0; - } - - // Check if process exited prematurely - if (process.HasExited) - { - var errorOutput = await process.StandardError.ReadToEndAsync(); - var standardOutput = await process.StandardOutput.ReadToEndAsync(); - - if (isJsonOutput) - { - var message = "Dev Proxy failed to start."; - if (!string.IsNullOrEmpty(errorOutput)) - { - message += $" {errorOutput.Trim()}"; - } - if (!string.IsNullOrEmpty(standardOutput)) - { - message += $" {standardOutput.Trim()}"; - } - await Console.Error.WriteLineAsync(FormatJsonLogEntry("error", message)); - } - else - { - await Console.Error.WriteLineAsync("Dev Proxy failed to start."); - if (!string.IsNullOrEmpty(errorOutput)) - { - await Console.Error.WriteLineAsync(errorOutput); - } - if (!string.IsNullOrEmpty(standardOutput)) - { - await Console.Error.WriteLineAsync(standardOutput); - } - } - return process.ExitCode; - } - } - - if (isJsonOutput) - { - await Console.Error.WriteLineAsync( - FormatJsonLogEntry("error", $"Timeout waiting for Dev Proxy to start. Check the log folder: {StateManager.GetLogsFolder()}")); - } - else - { - await Console.Error.WriteLineAsync("Timeout waiting for Dev Proxy to start."); - await Console.Error.WriteLineAsync($"Check the log folder: {StateManager.GetLogsFolder()}"); - } - return 1; - } - catch (Exception ex) - { - if (isJsonOutput) - { - await Console.Error.WriteLineAsync( - FormatJsonLogEntry("error", $"Failed to start Dev Proxy: {ex.Message}")); - } - else - { - await Console.Error.WriteLineAsync($"Failed to start Dev Proxy: {ex.Message}"); - } - return 1; - } -} - -/// -/// Formats a result entry matching the JSONL envelope produced by JsonConsoleFormatter: -/// {"type":"result","data":{...},"timestamp":"..."} -/// -static string FormatJsonResultEntry(object data) -{ -#pragma warning disable CA1869 // Called at most once per process lifetime - var jsonlOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; -#pragma warning restore CA1869 - var entry = new - { - type = "result", - data = JsonSerializer.SerializeToElement(data, jsonlOptions), - timestamp = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture) - }; - return JsonSerializer.Serialize(entry, jsonlOptions); -} - -/// -/// Formats a log entry matching the JSONL envelope produced by JsonConsoleFormatter: -/// {"type":"log","level":"...","message":"...","timestamp":"..."} -/// -static string FormatJsonLogEntry(string level, string message) -{ -#pragma warning disable CA1869 // Called at most once per process lifetime - var jsonlOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; -#pragma warning restore CA1869 - var entry = new - { - type = "log", - level, - message, - timestamp = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture) - }; - return JsonSerializer.Serialize(entry, jsonlOptions); -} - -static bool IsJsonOutputRequested(string[] args) -{ - for (var i = 0; i < args.Length; i++) - { - if (string.Equals(args[i], DevProxyCommand.OutputOptionName, StringComparison.OrdinalIgnoreCase) && - i + 1 < args.Length) - { - return string.Equals(args[i + 1], "json", StringComparison.OrdinalIgnoreCase); - } - } - return false; -} - -static string EscapeArgument(string arg) -{ - // Simple escaping for command line arguments - if (arg.Contains(' ', StringComparison.Ordinal) || arg.Contains('"', StringComparison.Ordinal)) - { - return $"\"{arg.Replace("\"", "\\\"", StringComparison.Ordinal)}\""; - } - return arg; -} - -static WebApplication BuildApplication( - DevProxyConfigOptions options, ProcessEnvironment environment, HttpProxyEnvironment proxyEnvironment) -{ - // Don't pass command-line args to WebApplication.CreateBuilder because: - // 1. Dev Proxy uses System.CommandLine for CLI parsing, not ASP.NET Core's CommandLineConfigurationProvider - // 2. ConfigureDevProxyConfig clears all configuration sources anyway and only uses JSON config files - var builder = WebApplication.CreateBuilder(); - - _ = builder.Configuration.ConfigureDevProxyConfig(options); - environment.Apply(builder.Configuration, options.Env); - var changedProxyVariables = proxyEnvironment.GetChangedVariables(); - _ = Announcement.ShowOnceAsync(); - _ = builder.Logging.ConfigureDevProxyLogging(builder.Configuration, options); - var allowedOrigins = ApiSecurity.GetAllowedOrigins(builder.Configuration); - _ = builder.Services.ConfigureDevProxyServices(builder.Configuration, options, allowedOrigins); - - var defaultApiPort = 8897; - var apiPort = options.ApiPort ?? - builder.Configuration.GetValue("apiPort", defaultApiPort); - var apiAddressText = options.ApiIpAddress ?? builder.Configuration.GetValue("apiIpAddress", "127.0.0.1"); - if (!IPAddress.TryParse(apiAddressText, out var apiAddress)) - { - throw new InvalidOperationException("apiIpAddress must be an IP address, for example 127.0.0.1 or ::1."); - } - _ = builder.WebHost.ConfigureKestrel(options => - { - options.Listen(apiAddress, apiPort); - }); - - var app = builder.Build(); - - foreach (var provider in ((IConfigurationRoot)builder.Configuration).Providers.OfType()) - { - EnvironmentVariablePlaceholders.LogUnresolvedVariables( - app.Logger, provider.UnresolvedEnvironmentVariables, provider.Path ?? "configuration file"); - } - - if (changedProxyVariables.Length > 0) - { - app.Logger.LogWarning( - "Proxy-routing environment variables changed ({EnvironmentVariables}). HTTP proxy settings are cached and cannot be refreshed by hot reload. Stop Dev Proxy and start it again to apply these changes.", - string.Join(", ", changedProxyVariables)); - } - - _ = app.Use((context, next) => ApiSecurity.CheckOriginAsync(context, next, allowedOrigins)); - _ = app.UseCors(); - _ = app.Use(ApiSecurity.AuthenticateAsync); - _ = app.MapControllers(); - - return app; -} - -static async Task RunProxyAsync(string[] args, DevProxyConfigOptions options, HttpProxyEnvironment proxyEnvironment) -{ - // Keep overrides through host shutdown, but restore inherited values before a hot reload. - using var environment = new ProcessEnvironment(); - var app = BuildApplication(options, environment, proxyEnvironment); - try - { - var devProxyCommand = app.Services.GetRequiredService(); - return await devProxyCommand.InvokeAsync(args, app); - } - finally - { - await app.DisposeAsync(); - } -} - -// Handle 'config validate' early to allow validation of broken configs -// without requiring the full DI container to be built -if (DevProxyCommand.IsConfigValidateCommand) -{ - _ = Announcement.ShowOnceAsync(); - var validateExitCode = await ConfigCommand.RunValidateStandaloneAsync(args); - Environment.Exit(validateExitCode); - return; -} - -var options = new DevProxyConfigOptions(); -options.ParseOptions(args); - -int exitCode; -bool shouldRestart; -var proxyEnvironment = new HttpProxyEnvironment(); -do -{ - try - { - // Reset the restart flag before each run - ConfigFileWatcher.Reset(); - // RunProxyAsync only returns after the host has fully stopped, which - // means the proxy engine's shutdown (including system proxy - // deregistration) has completed — so it's safe to start a new instance. - exitCode = await RunProxyAsync(args, options, proxyEnvironment); - - shouldRestart = ConfigFileWatcher.IsRestarting; - } - catch (Exception ex) - { - await Console.Error.WriteLineAsync("Unhandled exception during proxy run. Stopping restart loop."); - await Console.Error.WriteLineAsync(ex.ToString()); - exitCode = 1; - shouldRestart = false; - } -} while (shouldRestart); - -if (DevProxyCommand.IsRootCommand) -{ - await StateManager.DeleteStateAsync(); -} - -if (_detachedLogWriter is not null) -{ - await _detachedLogWriter.DisposeAsync(); -} - +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy; +using DevProxy.Abstractions.Utils; +using DevProxy.Commands; +using DevProxy.Proxy; +using DevProxy.State; +using System.Diagnostics; +using System.Globalization; +using System.Net; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +// Keep management files discoverable even when env overrides change user data paths. +_ = StateManager.GetConfigFolder(); + +// Handle detached mode - spawn a child process and exit +// Only applies to root command (starting the proxy), not subcommands +if (DevProxyCommand.IsRootCommand && + DevProxyCommand.IsDetachedMode && + !DevProxyCommand.IsInternalDaemon) +{ + var detachedExitCode = await StartDetachedProcessAsync(args); + Environment.Exit(detachedExitCode); + return; +} + +// For daemon mode, redirect Console.Out/Error to the log file +// so that the normal console formatters (ProxyConsoleFormatter, +// MachineConsoleFormatter) write to the file instead of stdout. +// Console.IsOutputRedirected becomes true, which automatically +// strips ANSI color codes from human-readable output. +StreamWriter? _detachedLogWriter = null; +if (DevProxyCommand.IsInternalDaemon) +{ + var logFilePath = DevProxyCommand.DetachedLogFilePath; + var logDir = Path.GetDirectoryName(logFilePath); + if (!string.IsNullOrEmpty(logDir)) + { + PrivateFiles.EnsureDirectory(StateManager.GetConfigFolder()); + PrivateFiles.EnsureDirectory(logDir); + } + +#pragma warning disable CA2000 // Lifetime managed manually; disposed before Environment.Exit + _detachedLogWriter = new StreamWriter(logFilePath, append: false, Encoding.UTF8) { AutoFlush = true }; +#pragma warning restore CA2000 + Console.SetOut(_detachedLogWriter); + Console.SetError(_detachedLogWriter); +} + +static async Task StartDetachedProcessAsync(string[] args) +{ + var isJsonOutput = IsJsonOutputRequested(args); + + // Recover from a system-proxy registration left behind by a previous + // instance that crashed before restoring the OS proxy. This must run before + // any state-loading call below, which prunes (deletes) the stale state files + // it depends on without restoring the proxy. + var reconciliation = await SystemProxyManager.ReconcileOrphanedSystemProxiesAsync(); + foreach (var orphan in reconciliation.Orphans) + { + var message = reconciliation.SystemProxyDisabled + ? $"Recovered system proxy left by a crashed Dev Proxy instance (PID: {orphan.Pid})." + : $"Removed stale system-proxy registration left by a crashed Dev Proxy instance (PID: {orphan.Pid}) while keeping the current system proxy owner unchanged."; + if (isJsonOutput) + { + await Console.Out.WriteLineAsync(FormatJsonLogEntry("info", message)); + } + else + { + await Console.Out.WriteLineAsync(message); + } + } + + // Check if an instance is already running as system proxy + var systemProxyInstance = await StateManager.FindSystemProxyInstanceAsync(); + if (systemProxyInstance is not null) + { + if (isJsonOutput) + { + await Console.Error.WriteLineAsync( + FormatJsonLogEntry("error", $"Dev Proxy is already running as system proxy (PID: {systemProxyInstance.Pid}). Use 'devproxy stop' to stop it first.")); + } + else + { + await Console.Error.WriteLineAsync($"Dev Proxy is already running as system proxy (PID: {systemProxyInstance.Pid})."); + await Console.Error.WriteLineAsync("Use 'devproxy stop' to stop it first."); + } + return 1; + } + + // Check for port conflicts with existing instances + var existingStates = await StateManager.LoadAllStatesAsync(); + if (existingStates.Count > 0) + { + var earlyOptions = new DevProxyConfigOptions(); + earlyOptions.ParseOptions(args); + var requestedPort = earlyOptions.Port ?? 8000; + var requestedApiPort = earlyOptions.ApiPort ?? 8897; + + foreach (var existing in existingStates) + { + if (requestedPort != 0 && existing.Port == requestedPort) + { + await Console.Error.WriteLineAsync($"Port {requestedPort} is already in use by Dev Proxy instance (PID: {existing.Pid})."); + await Console.Error.WriteLineAsync("Use a different --port or stop the existing instance first."); + return 1; + } + + if (requestedApiPort != 0 && existing.ApiUrl.EndsWith($":{requestedApiPort}", StringComparison.Ordinal)) + { + await Console.Error.WriteLineAsync($"API port {requestedApiPort} is already in use by Dev Proxy instance (PID: {existing.Pid})."); + await Console.Error.WriteLineAsync("Use a different --api-port or stop the existing instance first."); + return 1; + } + } + } + + // Clean up old log files + StateManager.CleanupOldLogs(); + + // Build the arguments for the daemon process + // Replace --detach/-d with --_internal-daemon + var daemonArgs = args + .Where(a => a is not "--detach" and not "-d") + .Append("--_internal-daemon") + .ToArray(); + + var executablePath = Environment.ProcessPath; + if (string.IsNullOrEmpty(executablePath)) + { + if (isJsonOutput) + { + await Console.Error.WriteLineAsync( + FormatJsonLogEntry("error", "Could not determine executable path.")); + } + else + { + await Console.Error.WriteLineAsync("Could not determine executable path."); + } + return 1; + } + + var startInfo = new ProcessStartInfo + { + FileName = executablePath, + Arguments = string.Join(" ", daemonArgs.Select(EscapeArgument)), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = Directory.GetCurrentDirectory() + }; + + // On Windows, we need to detach from the console + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + startInfo.WindowStyle = ProcessWindowStyle.Hidden; + } + + try + { + var process = Process.Start(startInfo); + if (process == null) + { + if (isJsonOutput) + { + await Console.Error.WriteLineAsync( + FormatJsonLogEntry("error", "Failed to start Dev Proxy process.")); + } + else + { + await Console.Error.WriteLineAsync("Failed to start Dev Proxy process."); + } + return 1; + } + + // Wait a moment for the proxy to start and write its state file + var timeout = TimeSpan.FromSeconds(10); + var stopwatch = Stopwatch.StartNew(); + + while (stopwatch.Elapsed < timeout) + { + await Task.Delay(200); + + var state = await StateManager.LoadStateByPidAsync(process.Id); + if (state is { Port: > 0 } && !string.IsNullOrEmpty(state.ApiUrl) && !state.ApiUrl.EndsWith(":0", StringComparison.Ordinal)) + { + var proxyUrl = state.ProxyUrl; + var token = ApiSecurity.ShouldDisplayTokenFor(state) ? + (await File.ReadAllTextAsync(ApiSecurity.GetTokenFilePath(state.Pid))).Trim() : null; + + if (isJsonOutput) + { + await Console.Out.WriteLineAsync(FormatJsonResultEntry(new + { + state.Pid, + ProxyUrl = proxyUrl, + state.ApiUrl, + Token = token, + state.LogFile + })); + } + else + { + var showToken = token is not null; + await Console.Out.WriteLineAsync("Dev Proxy started in background."); + await Console.Out.WriteLineAsync(); + await Console.Out.WriteLineAsync($" PID: {state.Pid}"); + await Console.Out.WriteLineAsync($" Proxy URL: {proxyUrl}"); + await Console.Out.WriteLineAsync($" API URL: {state.ApiUrl}"); + if (showToken) + { + await Console.Out.WriteLineAsync($" API token: {token}"); + } + await Console.Out.WriteLineAsync($" Log file: {state.LogFile}"); + await Console.Out.WriteLineAsync(); + if (!showToken) + { + await Console.Out.WriteLineAsync($"Use 'devproxy api token --pid {state.Pid}' to get the API token."); + } + await Console.Out.WriteLineAsync("Use 'devproxy status' to check status."); + await Console.Out.WriteLineAsync("Use 'devproxy logs' to view logs."); + await Console.Out.WriteLineAsync("Use 'devproxy stop' to stop."); + } + return 0; + } + + // Check if process exited prematurely + if (process.HasExited) + { + var errorOutput = await process.StandardError.ReadToEndAsync(); + var standardOutput = await process.StandardOutput.ReadToEndAsync(); + + if (isJsonOutput) + { + var message = "Dev Proxy failed to start."; + if (!string.IsNullOrEmpty(errorOutput)) + { + message += $" {errorOutput.Trim()}"; + } + if (!string.IsNullOrEmpty(standardOutput)) + { + message += $" {standardOutput.Trim()}"; + } + await Console.Error.WriteLineAsync(FormatJsonLogEntry("error", message)); + } + else + { + await Console.Error.WriteLineAsync("Dev Proxy failed to start."); + if (!string.IsNullOrEmpty(errorOutput)) + { + await Console.Error.WriteLineAsync(errorOutput); + } + if (!string.IsNullOrEmpty(standardOutput)) + { + await Console.Error.WriteLineAsync(standardOutput); + } + } + return process.ExitCode; + } + } + + if (isJsonOutput) + { + await Console.Error.WriteLineAsync( + FormatJsonLogEntry("error", $"Timeout waiting for Dev Proxy to start. Check the log folder: {StateManager.GetLogsFolder()}")); + } + else + { + await Console.Error.WriteLineAsync("Timeout waiting for Dev Proxy to start."); + await Console.Error.WriteLineAsync($"Check the log folder: {StateManager.GetLogsFolder()}"); + } + return 1; + } + catch (Exception ex) + { + if (isJsonOutput) + { + await Console.Error.WriteLineAsync( + FormatJsonLogEntry("error", $"Failed to start Dev Proxy: {ex.Message}")); + } + else + { + await Console.Error.WriteLineAsync($"Failed to start Dev Proxy: {ex.Message}"); + } + return 1; + } +} + +/// +/// Formats a result entry matching the JSONL envelope produced by JsonConsoleFormatter: +/// {"type":"result","data":{...},"timestamp":"..."} +/// +static string FormatJsonResultEntry(object data) +{ +#pragma warning disable CA1869 // Called at most once per process lifetime + var jsonlOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; +#pragma warning restore CA1869 + var entry = new + { + type = "result", + data = JsonSerializer.SerializeToElement(data, jsonlOptions), + timestamp = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture) + }; + return JsonSerializer.Serialize(entry, jsonlOptions); +} + +/// +/// Formats a log entry matching the JSONL envelope produced by JsonConsoleFormatter: +/// {"type":"log","level":"...","message":"...","timestamp":"..."} +/// +static string FormatJsonLogEntry(string level, string message) +{ +#pragma warning disable CA1869 // Called at most once per process lifetime + var jsonlOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; +#pragma warning restore CA1869 + var entry = new + { + type = "log", + level, + message, + timestamp = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture) + }; + return JsonSerializer.Serialize(entry, jsonlOptions); +} + +static bool IsJsonOutputRequested(string[] args) +{ + for (var i = 0; i < args.Length; i++) + { + if (string.Equals(args[i], DevProxyCommand.OutputOptionName, StringComparison.OrdinalIgnoreCase) && + i + 1 < args.Length) + { + return string.Equals(args[i + 1], "json", StringComparison.OrdinalIgnoreCase); + } + } + return false; +} + +static string EscapeArgument(string arg) +{ + // Simple escaping for command line arguments + if (arg.Contains(' ', StringComparison.Ordinal) || arg.Contains('"', StringComparison.Ordinal)) + { + return $"\"{arg.Replace("\"", "\\\"", StringComparison.Ordinal)}\""; + } + return arg; +} + +static WebApplication BuildApplication( + DevProxyConfigOptions options, ProcessEnvironment environment, HttpProxyEnvironment proxyEnvironment) +{ + // Don't pass command-line args to WebApplication.CreateBuilder because: + // 1. Dev Proxy uses System.CommandLine for CLI parsing, not ASP.NET Core's CommandLineConfigurationProvider + // 2. ConfigureDevProxyConfig clears all configuration sources anyway and only uses JSON config files + var builder = WebApplication.CreateBuilder(); + + _ = builder.Configuration.ConfigureDevProxyConfig(options); + environment.Apply(builder.Configuration, options.Env); + var changedProxyVariables = proxyEnvironment.GetChangedVariables(); + _ = Announcement.ShowOnceAsync(); + _ = builder.Logging.ConfigureDevProxyLogging(builder.Configuration, options); + var allowedOrigins = ApiSecurity.GetAllowedOrigins(builder.Configuration); + _ = builder.Services.ConfigureDevProxyServices(builder.Configuration, options, allowedOrigins); + + var defaultApiPort = 8897; + var apiPort = options.ApiPort ?? + builder.Configuration.GetValue("apiPort", defaultApiPort); + var apiAddressText = options.ApiIpAddress ?? builder.Configuration.GetValue("apiIpAddress", "127.0.0.1"); + if (!IPAddress.TryParse(apiAddressText, out var apiAddress)) + { + throw new InvalidOperationException("apiIpAddress must be an IP address, for example 127.0.0.1 or ::1."); + } + _ = builder.WebHost.ConfigureKestrel(options => + { + options.Listen(apiAddress, apiPort); + }); + + var app = builder.Build(); + + foreach (var provider in ((IConfigurationRoot)builder.Configuration).Providers.OfType()) + { + EnvironmentVariablePlaceholders.LogUnresolvedVariables( + app.Logger, provider.UnresolvedEnvironmentVariables, provider.Path ?? "configuration file"); + } + + if (changedProxyVariables.Length > 0) + { + app.Logger.LogWarning( + "Proxy-routing environment variables changed ({EnvironmentVariables}). HTTP proxy settings are cached and cannot be refreshed by hot reload. Stop Dev Proxy and start it again to apply these changes.", + string.Join(", ", changedProxyVariables)); + } + + _ = app.Use((context, next) => ApiSecurity.CheckOriginAsync(context, next, allowedOrigins)); + _ = app.UseCors(); + _ = app.Use(ApiSecurity.AuthenticateAsync); + _ = app.MapControllers(); + + return app; +} + +static async Task RunProxyAsync(string[] args, DevProxyConfigOptions options, HttpProxyEnvironment proxyEnvironment) +{ + // Keep overrides through host shutdown, but restore inherited values before a hot reload. + using var environment = new ProcessEnvironment(); + var app = BuildApplication(options, environment, proxyEnvironment); + try + { + var devProxyCommand = app.Services.GetRequiredService(); + return await devProxyCommand.InvokeAsync(args, app); + } + finally + { + await app.DisposeAsync(); + } +} + +// Handle 'config validate' early to allow validation of broken configs +// without requiring the full DI container to be built +if (DevProxyCommand.IsConfigValidateCommand) +{ + _ = Announcement.ShowOnceAsync(); + var validateExitCode = await ConfigCommand.RunValidateStandaloneAsync(args); + Environment.Exit(validateExitCode); + return; +} + +var options = new DevProxyConfigOptions(); +options.ParseOptions(args); + +int exitCode; +bool shouldRestart; +var proxyEnvironment = new HttpProxyEnvironment(); +do +{ + try + { + // Reset the restart flag before each run + ConfigFileWatcher.Reset(); + // RunProxyAsync only returns after the host has fully stopped, which + // means the proxy engine's shutdown (including system proxy + // deregistration) has completed — so it's safe to start a new instance. + exitCode = await RunProxyAsync(args, options, proxyEnvironment); + + shouldRestart = ConfigFileWatcher.IsRestarting; + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync("Unhandled exception during proxy run. Stopping restart loop."); + await Console.Error.WriteLineAsync(ex.ToString()); + exitCode = 1; + shouldRestart = false; + } +} while (shouldRestart); + +if (DevProxyCommand.IsRootCommand) +{ + await StateManager.DeleteStateAsync(); +} + +if (_detachedLogWriter is not null) +{ + await _detachedLogWriter.DisposeAsync(); +} + Environment.Exit(exitCode); \ No newline at end of file From e89006248a5c0bbf78f875cbf355e0020d43f104 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 14:27:55 +0100 Subject: [PATCH 7/7] Keep legacy JSON key substitution and report unresolved variables on startup failure - Replace legacy {NAME} variables in raw text for JSON API specs too, so keys like OpenAPI paths keep being substituted as before - Write unresolved placeholder warnings to stderr when startup fails before the logger is available Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../MinimalPermissionsUtilsTests.cs | 16 ++++++++++ .../Utils/MinimalPermissionsUtils.cs | 21 +++++--------- DevProxy/Program.cs | 29 +++++++++++++++++++ 3 files changed, 53 insertions(+), 13 deletions(-) diff --git a/DevProxy.Integration.Tests/MinimalPermissionsUtilsTests.cs b/DevProxy.Integration.Tests/MinimalPermissionsUtilsTests.cs index 30fbe27a7..1086c4cad 100644 --- a/DevProxy.Integration.Tests/MinimalPermissionsUtilsTests.cs +++ b/DevProxy.Integration.Tests/MinimalPermissionsUtilsTests.cs @@ -80,6 +80,22 @@ public void ResolveApiSpecVariables_Yaml_ResolvesUnquotedLegacyVariables() _ = Assert.Single(logger.Warnings, w => w.Contains("deprecated", StringComparison.Ordinal)); } + [Fact] + public void ResolveApiSpecVariables_Json_ResolvesLegacyVariablesInKeys() + { + var json = """{ "paths": { "/{TENANT_ID}/users/{id}": {}, "/$${TENANT_ID}": {} } }"""; + var logger = new WarningLogger(); + + var result = MinimalPermissionsUtils.ResolveApiSpecVariables(json, "spec.json", new() { ["TENANT_ID"] = "contoso" }, logger); + + using var doc = JsonDocument.Parse(result); + var paths = doc.RootElement.GetProperty("paths"); + Assert.True(paths.TryGetProperty("/contoso/users/{id}", out _)); + // keys aren't resolved, but the legacy pass must not change placeholders + Assert.True(paths.TryGetProperty("/$${TENANT_ID}", out _)); + _ = Assert.Single(logger.Warnings, w => w.Contains("deprecated", StringComparison.Ordinal)); + } + [Fact] public void ResolveApiSpecVariables_EscapedPlaceholderOnly_NoDeprecationWarning() { diff --git a/DevProxy.Plugins/Utils/MinimalPermissionsUtils.cs b/DevProxy.Plugins/Utils/MinimalPermissionsUtils.cs index af26ca9fa..4b204251a 100644 --- a/DevProxy.Plugins/Utils/MinimalPermissionsUtils.cs +++ b/DevProxy.Plugins/Utils/MinimalPermissionsUtils.cs @@ -36,19 +36,14 @@ string replaceLegacyVariables(string text) { // resolve values in the parsed document so that values with quotes don't break it // and keys aren't changed - if (Path.GetExtension(filePath).Equals(".json", StringComparison.OrdinalIgnoreCase)) - { - resolved = EnvironmentVariablePlaceholders.ResolveInJson(fileContents, Environment.GetEnvironmentVariable, unresolvedVariables, transformText); - } - else - { - // unquoted legacy values like `url: {NAME}` parse as YAML flow mappings, - // so replace legacy variables in the raw text first, like before - var withLegacyVariables = transformText is null - ? fileContents - : EnvironmentVariablePlaceholders.TransformOutsidePlaceholders(fileContents, transformText); - resolved = EnvironmentVariablePlaceholders.ResolveInYaml(withLegacyVariables, Environment.GetEnvironmentVariable, unresolvedVariables); - } + // legacy variables were always replaced in the raw text, including keys like + // OpenAPI paths and unquoted YAML values like `url: {NAME}`, so keep doing that + var withLegacyVariables = transformText is null + ? fileContents + : EnvironmentVariablePlaceholders.TransformOutsidePlaceholders(fileContents, transformText); + resolved = Path.GetExtension(filePath).Equals(".json", StringComparison.OrdinalIgnoreCase) + ? EnvironmentVariablePlaceholders.ResolveInJson(withLegacyVariables, Environment.GetEnvironmentVariable, unresolvedVariables) + : EnvironmentVariablePlaceholders.ResolveInYaml(withLegacyVariables, Environment.GetEnvironmentVariable, unresolvedVariables); } catch (Exception ex) when (ex is JsonException or YamlException) { diff --git a/DevProxy/Program.cs b/DevProxy/Program.cs index 47e4cad1c..a573ed74f 100644 --- a/DevProxy/Program.cs +++ b/DevProxy/Program.cs @@ -360,6 +360,19 @@ static string EscapeArgument(string arg) return arg; } +static void WriteUnresolvedEnvironmentVariables(IConfiguration configuration, DevProxyConfigOptions options) +{ + var isJson = options.Output == DevProxy.Abstractions.Proxy.OutputFormat.Json || IsJsonOutputRequested(Environment.GetCommandLineArgs()); + foreach (var provider in ((IConfigurationRoot)configuration).Providers.OfType()) + { + foreach (var name in provider.UnresolvedEnvironmentVariables) + { + var message = $"Environment variable {name} referenced in {provider.Path ?? "configuration file"} isn't set. Leaving the placeholder as-is"; + Console.Error.WriteLine(isJson ? FormatJsonLogEntry("warning", message) : message); + } + } +} + static WebApplication BuildApplication( DevProxyConfigOptions options, ProcessEnvironment environment, HttpProxyEnvironment proxyEnvironment) { @@ -369,6 +382,22 @@ static WebApplication BuildApplication( var builder = WebApplication.CreateBuilder(); _ = builder.Configuration.ConfigureDevProxyConfig(options); + try + { + return BuildApplicationCore(builder, options, environment, proxyEnvironment); + } + catch + { + // config values with unresolved placeholders can fail startup before + // they're logged, so report them to help troubleshoot + WriteUnresolvedEnvironmentVariables(builder.Configuration, options); + throw; + } +} + +static WebApplication BuildApplicationCore( + WebApplicationBuilder builder, DevProxyConfigOptions options, ProcessEnvironment environment, HttpProxyEnvironment proxyEnvironment) +{ environment.Apply(builder.Configuration, options.Env); var changedProxyVariables = proxyEnvironment.GetChangedVariables(); _ = Announcement.ShowOnceAsync();