Skip to content

Commit b976fa1

Browse files
committed
Merge branch 'main' of https://github.com/dotnet/dev-proxy into copilot/add-devproxy-config-validate
# Conflicts: # DevProxy/Commands/DevProxyCommand.cs
2 parents 4e809cd + 239c301 commit b976fa1

29 files changed

+2024
-39
lines changed
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
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.Utils;
6+
using YamlDotNet.RepresentationModel;
7+
8+
#pragma warning disable IDE0130
9+
namespace Microsoft.Extensions.Configuration;
10+
#pragma warning restore IDE0130
11+
12+
/// <summary>
13+
/// A YAML file configuration source.
14+
/// </summary>
15+
public sealed class YamlConfigurationSource : FileConfigurationSource
16+
{
17+
/// <inheritdoc/>
18+
public override IConfigurationProvider Build(IConfigurationBuilder builder)
19+
{
20+
EnsureDefaults(builder);
21+
return new YamlConfigurationProvider(this);
22+
}
23+
}
24+
25+
/// <summary>
26+
/// A YAML file configuration provider that supports anchors and merge keys.
27+
/// </summary>
28+
public sealed class YamlConfigurationProvider : FileConfigurationProvider
29+
{
30+
/// <summary>
31+
/// Initializes a new instance of the <see cref="YamlConfigurationProvider"/> class.
32+
/// </summary>
33+
/// <param name="source">The configuration source.</param>
34+
public YamlConfigurationProvider(YamlConfigurationSource source) : base(source)
35+
{
36+
}
37+
38+
/// <inheritdoc/>
39+
public override void Load(Stream stream)
40+
{
41+
using var reader = new StreamReader(stream);
42+
var yamlContent = reader.ReadToEnd();
43+
44+
// Parse the YAML using RepresentationModel which handles anchors/aliases natively
45+
var yaml = new YamlStream();
46+
using var stringReader = new StringReader(yamlContent);
47+
yaml.Load(stringReader);
48+
49+
Data = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
50+
51+
if (yaml.Documents.Count == 0 || yaml.Documents[0].RootNode is null)
52+
{
53+
return;
54+
}
55+
56+
if (yaml.Documents[0].RootNode is YamlMappingNode mappingNode)
57+
{
58+
FlattenYamlNode(mappingNode, string.Empty);
59+
}
60+
}
61+
62+
private void FlattenYamlNode(YamlNode node, string prefix)
63+
{
64+
switch (node)
65+
{
66+
case YamlMappingNode mappingNode:
67+
FlattenMappingNode(mappingNode, prefix);
68+
break;
69+
case YamlSequenceNode sequenceNode:
70+
FlattenSequenceNode(sequenceNode, prefix);
71+
break;
72+
case YamlScalarNode scalarNode:
73+
Data[prefix] = NormalizeScalar(scalarNode);
74+
break;
75+
}
76+
}
77+
78+
private void FlattenMappingNode(YamlMappingNode mappingNode, string prefix)
79+
{
80+
// First, collect all merge key values
81+
var mergedValues = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
82+
83+
foreach (var entry in mappingNode.Children)
84+
{
85+
var key = GetScalarValue(entry.Key);
86+
if (key is null)
87+
{
88+
continue;
89+
}
90+
91+
// Handle YAML merge key (<<)
92+
if (key == "<<")
93+
{
94+
if (entry.Value is YamlMappingNode mergeMapping)
95+
{
96+
CollectMergedValues(mergeMapping, string.Empty, mergedValues);
97+
}
98+
else if (entry.Value is YamlSequenceNode mergeSequence)
99+
{
100+
foreach (var item in mergeSequence.Children)
101+
{
102+
if (item is YamlMappingNode itemMapping)
103+
{
104+
CollectMergedValues(itemMapping, string.Empty, mergedValues);
105+
}
106+
}
107+
}
108+
}
109+
}
110+
111+
// Add merged values first (they can be overridden by explicit values)
112+
foreach (var kvp in mergedValues)
113+
{
114+
var fullKey = string.IsNullOrEmpty(prefix)
115+
? kvp.Key
116+
: $"{prefix}{ConfigurationPath.KeyDelimiter}{kvp.Key}";
117+
Data[fullKey] = kvp.Value;
118+
}
119+
120+
// Then process regular keys (they override merged values)
121+
foreach (var entry in mappingNode.Children)
122+
{
123+
var key = GetScalarValue(entry.Key);
124+
if (key is null)
125+
{
126+
continue;
127+
}
128+
129+
// Skip merge key
130+
if (key == "<<")
131+
{
132+
continue;
133+
}
134+
135+
var newPrefix = string.IsNullOrEmpty(prefix)
136+
? key
137+
: $"{prefix}{ConfigurationPath.KeyDelimiter}{key}";
138+
139+
FlattenYamlNode(entry.Value, newPrefix);
140+
}
141+
}
142+
143+
private static string? GetScalarValue(YamlNode node)
144+
{
145+
return node is YamlScalarNode scalarNode ? scalarNode.Value : null;
146+
}
147+
148+
private static string? NormalizeScalar(YamlScalarNode scalarNode)
149+
{
150+
var value = scalarNode.Value;
151+
if (value is null)
152+
{
153+
return null;
154+
}
155+
156+
// Only normalize plain (unquoted) scalars
157+
if (scalarNode.Style != YamlDotNet.Core.ScalarStyle.Plain)
158+
{
159+
return value;
160+
}
161+
162+
return value.ToLowerInvariant() switch
163+
{
164+
"y" or "yes" or "true" or "on" => "true",
165+
"n" or "no" or "false" or "off" => "false",
166+
"~" or "null" or "" => null,
167+
_ => value
168+
};
169+
}
170+
171+
private void CollectMergedValues(YamlMappingNode mappingNode, string prefix, Dictionary<string, string?> values)
172+
{
173+
foreach (var entry in mappingNode.Children)
174+
{
175+
var key = GetScalarValue(entry.Key);
176+
if (key is null)
177+
{
178+
continue;
179+
}
180+
181+
// Handle nested merge keys recursively
182+
if (key == "<<")
183+
{
184+
switch (entry.Value)
185+
{
186+
case YamlMappingNode mergeMapping:
187+
CollectMergedValues(mergeMapping, prefix, values);
188+
break;
189+
case YamlSequenceNode mergeSequence:
190+
foreach (var child in mergeSequence.Children)
191+
{
192+
if (child is YamlMappingNode childMapping)
193+
{
194+
CollectMergedValues(childMapping, prefix, values);
195+
}
196+
}
197+
break;
198+
}
199+
continue;
200+
}
201+
202+
var newPrefix = string.IsNullOrEmpty(prefix)
203+
? key
204+
: $"{prefix}{ConfigurationPath.KeyDelimiter}{key}";
205+
206+
CollectMergedValuesFromNode(entry.Value, newPrefix, values);
207+
}
208+
}
209+
210+
private void CollectMergedValuesFromNode(YamlNode node, string prefix, Dictionary<string, string?> values)
211+
{
212+
switch (node)
213+
{
214+
case YamlMappingNode mappingNode:
215+
CollectMergedValues(mappingNode, prefix, values);
216+
break;
217+
case YamlSequenceNode sequenceNode:
218+
for (int i = 0; i < sequenceNode.Children.Count; i++)
219+
{
220+
var newPrefix = $"{prefix}{ConfigurationPath.KeyDelimiter}{i}";
221+
CollectMergedValuesFromNode(sequenceNode.Children[i], newPrefix, values);
222+
}
223+
break;
224+
case YamlScalarNode scalarNode:
225+
// Later values override earlier values within merged content
226+
values[prefix] = scalarNode.Value;
227+
break;
228+
}
229+
}
230+
231+
private void FlattenSequenceNode(YamlSequenceNode sequenceNode, string prefix)
232+
{
233+
for (int i = 0; i < sequenceNode.Children.Count; i++)
234+
{
235+
var newPrefix = $"{prefix}{ConfigurationPath.KeyDelimiter}{i}";
236+
FlattenYamlNode(sequenceNode.Children[i], newPrefix);
237+
}
238+
}
239+
}
240+
241+
/// <summary>
242+
/// Extension methods for adding YAML configuration.
243+
/// </summary>
244+
public static class YamlConfigurationExtensions
245+
{
246+
/// <summary>
247+
/// Adds a YAML configuration source to the configuration builder.
248+
/// </summary>
249+
/// <param name="builder">The configuration builder.</param>
250+
/// <param name="path">The path to the YAML file.</param>
251+
/// <param name="optional">Whether the file is optional.</param>
252+
/// <param name="reloadOnChange">Whether to reload on change.</param>
253+
/// <returns>The configuration builder.</returns>
254+
public static IConfigurationBuilder AddYamlFile(
255+
this IConfigurationBuilder builder,
256+
string path,
257+
bool optional = false,
258+
bool reloadOnChange = false)
259+
{
260+
ArgumentNullException.ThrowIfNull(builder);
261+
ArgumentException.ThrowIfNullOrEmpty(path);
262+
263+
return builder.Add<YamlConfigurationSource>(s =>
264+
{
265+
s.FileProvider = null;
266+
s.Path = path;
267+
s.Optional = optional;
268+
s.ReloadOnChange = reloadOnChange;
269+
s.ResolveFileProvider();
270+
});
271+
}
272+
273+
/// <summary>
274+
/// Adds a configuration file (JSON or YAML based on extension).
275+
/// </summary>
276+
/// <param name="builder">The configuration builder.</param>
277+
/// <param name="path">The path to the configuration file.</param>
278+
/// <param name="optional">Whether the file is optional.</param>
279+
/// <param name="reloadOnChange">Whether to reload on change.</param>
280+
/// <returns>The configuration builder.</returns>
281+
public static IConfigurationBuilder AddConfigFile(
282+
this IConfigurationBuilder builder,
283+
string path,
284+
bool optional = false,
285+
bool reloadOnChange = false)
286+
{
287+
ArgumentNullException.ThrowIfNull(builder);
288+
ArgumentException.ThrowIfNullOrEmpty(path);
289+
290+
if (ProxyYaml.IsYamlFile(path))
291+
{
292+
return builder.AddYamlFile(path, optional, reloadOnChange);
293+
}
294+
295+
return builder.AddJsonFile(path, optional, reloadOnChange);
296+
}
297+
}

DevProxy.Abstractions/Plugins/BaseLoader.cs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,27 @@ private async Task LoadFileContentsAsync(CancellationToken cancellationToken)
120120
{
121121
using var stream = new FileStream(FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
122122
using var reader = new StreamReader(stream);
123-
var responsesString = await reader.ReadToEndAsync(cancellationToken);
123+
var fileContents = await reader.ReadToEndAsync(cancellationToken);
124124

125-
if (!_validateSchemas || await ValidateFileContentsAsync(responsesString, cancellationToken))
125+
// Convert YAML to JSON if needed
126+
string jsonContents;
127+
if (ProxyYaml.IsYamlFile(FilePath))
126128
{
127-
LoadData(responsesString);
129+
if (!ProxyYaml.TryConvertYamlToJson(fileContents, out var converted, out var error))
130+
{
131+
Logger.LogError("Failed to parse YAML file {File}: {Error}", FilePath, error);
132+
return;
133+
}
134+
jsonContents = converted!;
135+
}
136+
else
137+
{
138+
jsonContents = fileContents;
139+
}
140+
141+
if (!_validateSchemas || await ValidateFileContentsAsync(jsonContents, cancellationToken))
142+
{
143+
LoadData(jsonContents);
128144
}
129145
}
130146
catch (Exception ex)

0 commit comments

Comments
 (0)