Skip to content

Commit 628a98d

Browse files
Merge branch 'main' into optimize-copilot-instructions
2 parents 3062a34 + 6ea59ab commit 628a98d

27 files changed

+1183
-116
lines changed

.github/workflows/create-release.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ jobs:
103103
run: |
104104
$(Get-FileHash ./${{ env.release }}.zip -Algorithm SHA256).Hash
105105
- name: Upload release
106-
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
106+
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
107107
with:
108108
name: binaries-${{ env.release }}
109109
path: ./${{ env.release }}.zip
@@ -131,7 +131,7 @@ jobs:
131131
exclusions: '*.json'
132132
- name: Upload abstractions
133133
if: matrix.architecture == 'win-x64'
134-
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
134+
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
135135
with:
136136
name: binaries-dev-proxy-abstractions-${{ github.ref_name }}
137137
path: ./DevProxy.Abstractions-${{ github.ref_name }}.zip
@@ -185,7 +185,7 @@ jobs:
185185
--verbosity Debug
186186
- name: Upload Installer
187187
if: contains(matrix.architecture, 'win-')
188-
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
188+
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
189189
with:
190190
name: installer-dev-proxy-${{ github.ref_name }}-${{ matrix.architecture }}
191191
path: ./${{ env.release }}/dev-proxy-installer-${{ matrix.architecture }}-${{ github.ref_name }}.exe
@@ -203,7 +203,7 @@ jobs:
203203
contents: write
204204
steps:
205205
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
206-
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
206+
- uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
207207
with:
208208
path: output
209209
- name: Release

DevProxy.Abstractions/Proxy/IProxyConfiguration.cs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@ public enum ReleaseType
1818
Beta
1919
}
2020

21-
public enum LogFor
21+
public enum OutputFormat
2222
{
23-
[EnumMember(Value = "human")]
24-
Human,
25-
[EnumMember(Value = "machine")]
26-
Machine
23+
[EnumMember(Value = "text")]
24+
Text,
25+
[EnumMember(Value = "json")]
26+
Json
2727
}
2828

2929
public interface IProxyConfiguration
@@ -37,10 +37,11 @@ public interface IProxyConfiguration
3737
IEnumerable<MockRequestHeader>? FilterByHeaders { get; }
3838
bool InstallCert { get; set; }
3939
string? IPAddress { get; set; }
40-
LogFor LogFor { get; set; }
40+
OutputFormat Output { get; set; }
4141
LogLevel LogLevel { get; }
4242
ReleaseType NewVersionNotification { get; }
4343
bool NoFirstRun { get; set; }
44+
bool NoWatch { get; set; }
4445
int Port { get; set; }
4546
bool Record { get; set; }
4647
bool ShowTimestamps { get; }

DevProxy.Abstractions/Utils/ProxyUtils.cs

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using Newtonsoft.Json.Schema;
1010
using System.Collections.ObjectModel;
1111
using System.Reflection;
12+
using System.Text.Encodings.Web;
1213
using System.Text.Json;
1314
using System.Text.Json.Serialization;
1415
using System.Text.RegularExpressions;
@@ -44,6 +45,7 @@ public static class ProxyUtils
4445
public static JsonSerializerOptions JsonSerializerOptions { get; } = new()
4546
{
4647
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
48+
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
4749
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
4850
PropertyNameCaseInsensitive = true,
4951
ReadCommentHandling = JsonCommentHandling.Skip,
@@ -273,6 +275,25 @@ public static void ValidateSchemaVersion(string schemaUrl, ILogger logger)
273275
return;
274276
}
275277

278+
var warning = GetSchemaVersionMismatchWarning(schemaUrl);
279+
if (warning is not null)
280+
{
281+
logger.LogWarning("{Warning}", warning);
282+
}
283+
}
284+
285+
/// <summary>
286+
/// Checks if the schema URL version matches the current Dev Proxy version.
287+
/// Returns a warning message if versions don't match, or null if they match
288+
/// or the schema URL cannot be parsed.
289+
/// </summary>
290+
public static string? GetSchemaVersionMismatchWarning(string schemaUrl)
291+
{
292+
if (string.IsNullOrWhiteSpace(schemaUrl))
293+
{
294+
return null;
295+
}
296+
276297
try
277298
{
278299
var uri = new Uri(schemaUrl);
@@ -285,18 +306,47 @@ public static void ValidateSchemaVersion(string schemaUrl, ILogger logger)
285306
if (CompareSemVer(currentVersion, schemaVersion) != 0)
286307
{
287308
var currentSchemaUrl = uri.ToString().Replace($"/v{schemaVersion}/", $"/v{currentVersion}/", StringComparison.OrdinalIgnoreCase);
288-
logger.LogWarning("The version of schema does not match the installed Dev Proxy version, the expected schema is {Schema}", currentSchemaUrl);
309+
return $"The version of schema does not match the installed Dev Proxy version, the expected schema is {currentSchemaUrl}";
289310
}
290311
}
291-
else
292-
{
293-
logger.LogDebug("Invalid schema {SchemaUrl}, skipping schema version validation.", schemaUrl);
294-
}
295312
}
296-
catch (Exception ex)
313+
catch
297314
{
298-
logger.LogWarning("Invalid schema {SchemaUrl}, skipping schema version validation. Error: {Error}", schemaUrl, ex.Message);
315+
return $"The $schema value '{schemaUrl}' is not a valid URL. Schema version could not be validated.";
299316
}
317+
318+
return null;
319+
}
320+
321+
/// <summary>
322+
/// Returns the ordered list of config file paths to search.
323+
/// The first existing file in the list should be used.
324+
/// </summary>
325+
public static IEnumerable<string?> GetConfigFileCandidates(string? userConfigFile)
326+
{
327+
return [
328+
// config file specified by the user takes precedence
329+
// null if not specified
330+
userConfigFile,
331+
// current directory - JSON/JSONC files
332+
"devproxyrc.jsonc",
333+
"devproxyrc.json",
334+
// current directory - YAML files
335+
"devproxyrc.yaml",
336+
"devproxyrc.yml",
337+
// .devproxy subdirectory - JSON/JSONC files
338+
Path.Combine(".devproxy", "devproxyrc.jsonc"),
339+
Path.Combine(".devproxy", "devproxyrc.json"),
340+
// .devproxy subdirectory - YAML files
341+
Path.Combine(".devproxy", "devproxyrc.yaml"),
342+
Path.Combine(".devproxy", "devproxyrc.yml"),
343+
// app folder - JSON/JSONC files
344+
Path.Combine(AppFolder ?? "", "devproxyrc.jsonc"),
345+
Path.Combine(AppFolder ?? "", "devproxyrc.json"),
346+
// app folder - YAML files
347+
Path.Combine(AppFolder ?? "", "devproxyrc.yaml"),
348+
Path.Combine(AppFolder ?? "", "devproxyrc.yml")
349+
];
300350
}
301351

302352
/// <summary>

DevProxy.Plugins/Generation/HarGeneratorPlugin.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
using Microsoft.Extensions.Configuration;
1010
using Microsoft.Extensions.Logging;
1111
using System.Diagnostics;
12+
using System.Globalization;
1213
using System.Text.Json;
14+
using System.Text.RegularExpressions;
1315
using System.Web;
1416

1517
namespace DevProxy.Plugins.Generation;
@@ -33,6 +35,8 @@ public sealed class HarGeneratorPlugin(
3335
proxyConfiguration,
3436
pluginConfigurationSection)
3537
{
38+
private static readonly Regex surrogatePairRegex = new(@"\\u([dD][89aAbB][0-9a-fA-F]{2})\\u([dD][cCdDeEfF][0-9a-fA-F]{2})");
39+
3640
public override string Name => nameof(HarGeneratorPlugin);
3741

3842
public override async Task AfterRecordingStopAsync(RecordingArgs e, CancellationToken cancellationToken)
@@ -69,6 +73,7 @@ r.Context.Session is not null &&
6973

7074
Logger.LogDebug("Serializing HAR file...");
7175
var harFileJson = JsonSerializer.Serialize(harFile, ProxyUtils.JsonSerializerOptions);
76+
harFileJson = UnescapeSurrogatePairs(harFileJson);
7277
var fileName = $"devproxy-{DateTime.Now:yyyyMMddHHmmss}.har";
7378

7479
Logger.LogDebug("Writing HAR file to {FileName}...", fileName);
@@ -159,4 +164,15 @@ private HarEntry CreateHarEntry(RequestLog log)
159164

160165
return entry;
161166
}
167+
168+
private static string UnescapeSurrogatePairs(string json)
169+
{
170+
return surrogatePairRegex.Replace(json, match =>
171+
{
172+
var high = int.Parse(match.Groups[1].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
173+
var low = int.Parse(match.Groups[2].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
174+
var codePoint = 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00);
175+
return char.ConvertFromUtf32(codePoint);
176+
});
177+
}
162178
}

DevProxy/Commands/ApiCommand.cs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using DevProxy.Abstractions.Proxy;
6+
using DevProxy.Abstractions.Utils;
7+
using Microsoft.Extensions.Logging;
8+
using System.CommandLine;
9+
using System.CommandLine.Parsing;
10+
using System.Text.Json;
11+
12+
namespace DevProxy.Commands;
13+
14+
sealed class ApiCommand : Command
15+
{
16+
private readonly IProxyConfiguration _proxyConfiguration;
17+
private readonly ILogger _logger;
18+
19+
public ApiCommand(IProxyConfiguration proxyConfiguration, ILogger<ApiCommand> logger) :
20+
base("api", "Manage Dev Proxy API information")
21+
{
22+
_proxyConfiguration = proxyConfiguration;
23+
_logger = logger;
24+
ConfigureCommand();
25+
}
26+
27+
private void ConfigureCommand()
28+
{
29+
var apiShowCommand = new Command("show", "Display Dev Proxy API information for runtime management");
30+
apiShowCommand.SetAction(parseResult =>
31+
{
32+
var outputFormat = parseResult.GetValueOrDefault<OutputFormat?>(DevProxyCommand.OutputOptionName) ?? OutputFormat.Text;
33+
PrintApiInfo(outputFormat);
34+
});
35+
36+
this.AddCommands(new List<Command>
37+
{
38+
apiShowCommand
39+
}.OrderByName());
40+
}
41+
42+
private void PrintApiInfo(OutputFormat outputFormat)
43+
{
44+
var ipAddress = _proxyConfiguration.IPAddress;
45+
var apiPort = _proxyConfiguration.ApiPort;
46+
var baseUrl = $"http://{ipAddress}:{apiPort}";
47+
48+
var endpoints = new[]
49+
{
50+
new ApiEndpointInfo { Method = "GET", Path = "/proxy", Description = "Get proxy status" },
51+
new ApiEndpointInfo { Method = "POST", Path = "/proxy", Description = "Update proxy status (e.g. start/stop recording)" },
52+
new ApiEndpointInfo { Method = "POST", Path = "/proxy/mockRequest", Description = "Issue a mock request" },
53+
new ApiEndpointInfo { Method = "POST", Path = "/proxy/stopProxy", Description = "Stop the proxy" },
54+
new ApiEndpointInfo { Method = "POST", Path = "/proxy/jwtToken", Description = "Create a JWT token" },
55+
new ApiEndpointInfo { Method = "GET", Path = "/proxy/rootCertificate", Description = "Get the root certificate" },
56+
new ApiEndpointInfo { Method = "GET", Path = "/proxy/logs", Description = "Get proxy logs (for detached mode access)" }
57+
};
58+
59+
if (outputFormat == OutputFormat.Json)
60+
{
61+
var json = JsonSerializer.Serialize(new
62+
{
63+
baseUrl,
64+
swaggerUrl = $"{baseUrl}/swagger/v1/swagger.json",
65+
endpoints = endpoints.Select(e => new
66+
{
67+
method = e.Method,
68+
path = e.Path,
69+
description = e.Description
70+
})
71+
}, ProxyUtils.JsonSerializerOptions);
72+
_logger.LogStructuredOutput(json);
73+
}
74+
else
75+
{
76+
_logger.LogInformation("Base URL: {BaseUrl}", baseUrl);
77+
_logger.LogInformation("OpenAPI spec: {SwaggerUrl}", $"{baseUrl}/swagger/v1/swagger.json");
78+
_logger.LogInformation("");
79+
_logger.LogInformation("Endpoints:");
80+
foreach (var endpoint in endpoints)
81+
{
82+
_logger.LogInformation(" {Method,-6} {Path,-30} {Description}", endpoint.Method, endpoint.Path, endpoint.Description);
83+
}
84+
}
85+
}
86+
}
87+
88+
sealed class ApiEndpointInfo
89+
{
90+
public string Method { get; set; } = string.Empty;
91+
public string Path { get; set; } = string.Empty;
92+
public string Description { get; set; } = string.Empty;
93+
}

DevProxy/Commands/CertCommand.cs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ sealed class CertCommand : Command
1515
private readonly ILoggerFactory _loggerFactory;
1616
private readonly Option<bool> _forceOption = new("--force", "-f")
1717
{
18-
Description = "Don't prompt for confirmation when removing the certificate"
18+
Description = "Don't prompt for confirmation when removing the certificate. Required for non-interactive use (CI, piped stdin, automation)."
1919
};
2020

2121
public CertCommand(ILogger<CertCommand> logger, ILoggerFactory loggerFactory) :
@@ -41,6 +41,11 @@ private void ConfigureCommand()
4141
certEnsureCommand,
4242
certRemoveCommand,
4343
}.OrderByName());
44+
45+
HelpExamples.Add(this, [
46+
"devproxy cert ensure Install and trust certificate",
47+
"devproxy cert remove --force Remove certificate (no prompt)",
48+
]);
4449
}
4550

4651
private async Task EnsureCertAsync()
@@ -74,7 +79,7 @@ private async Task EnsureCertAsync()
7479
_logger.LogTrace("EnsureCertAsync() finished");
7580
}
7681

77-
public void RemoveCert(ParseResult parseResult)
82+
public int RemoveCert(ParseResult parseResult)
7883
{
7984
_logger.LogTrace("RemoveCert() called");
8085

@@ -83,10 +88,17 @@ public void RemoveCert(ParseResult parseResult)
8388
var isForced = parseResult.GetValue(_forceOption);
8489
if (!isForced)
8590
{
91+
if (Console.IsInputRedirected ||
92+
Environment.GetEnvironmentVariable("CI") is not null)
93+
{
94+
_logger.LogError("Confirmation required but running in non-interactive mode. Use --force to skip confirmation.");
95+
return 1;
96+
}
97+
8698
var isConfirmed = PromptConfirmation("Do you want to remove the root certificate", acceptByDefault: false);
8799
if (!isConfirmed)
88100
{
89-
return;
101+
return 0;
90102
}
91103
}
92104

@@ -111,10 +123,12 @@ public void RemoveCert(ParseResult parseResult)
111123
}
112124

113125
_logger.LogInformation("DONE");
126+
return 0;
114127
}
115128
catch (Exception ex)
116129
{
117130
_logger.LogError(ex, "Error removing certificate");
131+
return 1;
118132
}
119133
finally
120134
{

0 commit comments

Comments
 (0)