Skip to content

Commit 4f49f9d

Browse files
Copilotdavidfowlcaptainsafia
authored
Support parameter names with dashes resolved from underscore configuration (#11785)
* Initial plan * Add parameter name normalization support for dashes to underscores Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Refine parameter normalization to only apply to default configuration keys Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Simplify normalization logic to apply to all configuration keys Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Update src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs * Make GetParameterValue public and update OpenAI/GitHub Models to use normalization Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Revert public API and duplicate normalization logic in extensions Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Fix ParameterProcessor to skip GenerateParameterDefault exception for non-empty values Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Add tests for GenerateParameterDefault behavior in publish mode Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Make GetParameterValue internal and use it in ParameterProcessor to distinguish configured from generated values Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Apply suggestions from code review * Replace obsolete parameterResource.Value with GetValueAsync in tests Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Fix GenerateParameterDefault tests to provide ServiceProvider with IConfiguration Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Update src/Aspire.Hosting/Orchestrator/ParameterProcessor.cs * Update src/Aspire.Hosting/Orchestrator/ParameterProcessor.cs * Move normalization logic to shared helper in IConfigurationExtensions Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Use extension method syntax for GetValueWithNormalizedKey across all files Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> Co-authored-by: David Fowler <davidfowl@gmail.com> Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com>
1 parent 3824ae7 commit 4f49f9d

File tree

9 files changed

+235
-9
lines changed

9 files changed

+235
-9
lines changed

src/Aspire.Hosting.GitHub.Models/Aspire.Hosting.GitHub.Models.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
<Compile Remove="tools\**\*.cs" />
1414
</ItemGroup>
1515

16+
<ItemGroup>
17+
<Compile Include="$(SharedDir)IConfigurationExtensions.cs" Link="IConfigurationExtensions.cs" />
18+
</ItemGroup>
19+
1620
<ItemGroup>
1721
<ProjectReference Include="..\Aspire.Hosting\Aspire.Hosting.csproj" />
1822
</ItemGroup>

src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,14 @@ public static IResourceBuilder<GitHubModelResource> AddGitHubModel(this IDistrib
2929
ArgumentException.ThrowIfNullOrEmpty(model);
3030

3131
var defaultApiKeyParameter = builder.AddParameter($"{name}-gh-apikey", () =>
32-
builder.Configuration[$"Parameters:{name}-gh-apikey"] ??
33-
Environment.GetEnvironmentVariable("GITHUB_TOKEN") ??
34-
throw new MissingParameterValueException($"GitHub API key parameter '{name}-gh-apikey' is missing and GITHUB_TOKEN environment variable is not set."),
32+
{
33+
var configKey = $"Parameters:{name}-gh-apikey";
34+
var value = builder.Configuration.GetValueWithNormalizedKey(configKey);
35+
36+
return value ??
37+
Environment.GetEnvironmentVariable("GITHUB_TOKEN") ??
38+
throw new MissingParameterValueException($"GitHub API key parameter '{name}-gh-apikey' is missing and GITHUB_TOKEN environment variable is not set.");
39+
},
3540
secret: true);
3641
defaultApiKeyParameter.Resource.Description = """
3742
The API key used to authenticate requests to the GitHub Models API.

src/Aspire.Hosting.OpenAI/Aspire.Hosting.OpenAI.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
<SuppressFinalPackageVersion>true</SuppressFinalPackageVersion>
1010
</PropertyGroup>
1111

12+
<ItemGroup>
13+
<Compile Include="$(SharedDir)IConfigurationExtensions.cs" Link="IConfigurationExtensions.cs" />
14+
</ItemGroup>
15+
1216
<ItemGroup>
1317
<ProjectReference Include="..\Aspire.Hosting\Aspire.Hosting.csproj" />
1418
</ItemGroup>

src/Aspire.Hosting.OpenAI/OpenAIExtensions.cs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,14 @@ public static IResourceBuilder<OpenAIResource> AddOpenAI(this IDistributedApplic
2525
ArgumentException.ThrowIfNullOrEmpty(name);
2626

2727
var defaultApiKeyParameter = builder.AddParameter($"{name}-openai-apikey", () =>
28-
builder.Configuration[$"Parameters:{name}-openai-apikey"] ??
29-
Environment.GetEnvironmentVariable("OPENAI_API_KEY") ??
30-
throw new MissingParameterValueException($"OpenAI API key parameter '{name}-openai-apikey' is missing and OPENAI_API_KEY environment variable is not set."),
28+
{
29+
var configKey = $"Parameters:{name}-openai-apikey";
30+
var value = builder.Configuration.GetValueWithNormalizedKey(configKey);
31+
32+
return value ??
33+
Environment.GetEnvironmentVariable("OPENAI_API_KEY") ??
34+
throw new MissingParameterValueException($"OpenAI API key parameter '{name}-openai-apikey' is missing and OPENAI_API_KEY environment variable is not set.");
35+
},
3136
secret: true);
3237
defaultApiKeyParameter.Resource.Description = """
3338
The API key used to authenticate requests to the OpenAI API.

src/Aspire.Hosting/Orchestrator/ParameterProcessor.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
using Aspire.Dashboard.Model;
88
using Aspire.Hosting.ApplicationModel;
99
using Aspire.Hosting.Resources;
10+
using Microsoft.Extensions.Configuration;
11+
using Microsoft.Extensions.DependencyInjection;
1012
using Microsoft.Extensions.Logging;
1113
using Microsoft.Extensions.SecretManager.Tools.Internal;
1214

@@ -163,9 +165,14 @@ private async Task ProcessParameterAsync(ParameterResource parameterResource)
163165
{
164166
var value = parameterResource.ValueInternal ?? "";
165167

168+
// Check if we need to validate GenerateParameterDefault in publish mode
169+
// We use GetParameterValue to distinguish between configured values and generated values
170+
// because ValueInternal might contain a generated value even if no configuration was provided.
166171
if (parameterResource.Default is GenerateParameterDefault generateDefault && executionContext.IsPublishMode)
167172
{
168-
throw new MissingParameterValueException("GenerateParameterDefault is not supported in this context. Falling back to prompting.");
173+
// Try to get a configured value (without using the default) to see if the parameter was actually specified. This will throw if the value is missing.
174+
var configuration = executionContext.ServiceProvider.GetRequiredService<IConfiguration>();
175+
value = ParameterResourceBuilderExtensions.GetParameterValue(configuration, parameterResource.Name, parameterDefault: null, parameterResource.ConfigurationKey);
169176
}
170177

171178
await notificationService.PublishUpdateAsync(parameterResource, s =>

src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,10 +201,16 @@ public static IResourceBuilder<ParameterResource> WithCustomInput(this IResource
201201
return builder;
202202
}
203203

204-
private static string GetParameterValue(ConfigurationManager configuration, string name, ParameterDefault? parameterDefault, string? configurationKey = null)
204+
// Internal to allow ParameterProcessor to check for configured values
205+
// without triggering default value generation
206+
internal static string GetParameterValue(IConfiguration configuration, string name, ParameterDefault? parameterDefault, string? configurationKey = null)
205207
{
206208
configurationKey ??= $"Parameters:{name}";
207-
return configuration[configurationKey]
209+
210+
// Use the shared helper to get the value with normalization support
211+
var value = configuration.GetValueWithNormalizedKey(configurationKey);
212+
213+
return value
208214
?? parameterDefault?.GetDefaultValue()
209215
?? throw new MissingParameterValueException($"Parameter resource could not be used because configuration key '{configurationKey}' is missing and the Parameter has no default value.");
210216
}

src/Shared/IConfigurationExtensions.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,4 +210,30 @@ public static T GetEnum<T>(this IConfiguration configuration, string key)
210210

211211
return value.Value;
212212
}
213+
214+
/// <summary>
215+
/// Gets a configuration value with support for dash-to-underscore normalization.
216+
/// First tries the exact configuration key, then tries with dashes replaced by underscores.
217+
/// </summary>
218+
/// <remarks>
219+
/// This supports command-line arguments and environment variables where dashes are replaced with underscores.
220+
/// For example, a parameter named "my-param" can be resolved from configuration key "my_param".
221+
/// </remarks>
222+
/// <param name="configuration">The <see cref="IConfiguration"/> this method extends.</param>
223+
/// <param name="configKey">The configuration key to look up.</param>
224+
/// <returns>The configuration value, or <see langword="null"/> if not found.</returns>
225+
public static string? GetValueWithNormalizedKey(this IConfiguration configuration, string configKey)
226+
{
227+
// First try to get the value with the exact configuration key
228+
var value = configuration[configKey];
229+
230+
// If not found, try with underscores as a fallback
231+
if (string.IsNullOrEmpty(value))
232+
{
233+
var normalizedKey = configKey.Replace("-", "_", StringComparison.Ordinal);
234+
value = configuration[normalizedKey];
235+
}
236+
237+
return value;
238+
}
213239
}

tests/Aspire.Hosting.Tests/AddParameterTests.cs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,4 +500,100 @@ public void ParameterWithCustomInput_AddsInputGeneratorAnnotation()
500500
Assert.False(input.EnableDescriptionMarkdown);
501501
}
502502
#pragma warning restore ASPIREINTERACTION001
503+
504+
[Fact]
505+
public async Task ParameterWithDashInName_CanBeResolvedWithUnderscoreInConfiguration()
506+
{
507+
// Arrange
508+
var appBuilder = DistributedApplication.CreateBuilder();
509+
510+
// Configuration using underscore instead of dash (as would come from environment variables or command line)
511+
appBuilder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
512+
{
513+
["Parameters:storage_account_name"] = "test-storage-account"
514+
});
515+
516+
// Act - parameter defined with dash
517+
appBuilder.AddParameter("storage-account-name");
518+
519+
using var app = appBuilder.Build();
520+
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
521+
var parameterResource = Assert.Single(appModel.Resources.OfType<ParameterResource>());
522+
523+
// Assert - should resolve to the value set with underscore
524+
Assert.Equal("test-storage-account", await parameterResource.GetValueAsync(default));
525+
}
526+
527+
[Fact]
528+
public async Task ParameterWithDashInName_PrefersDashConfigurationOverUnderscore()
529+
{
530+
// Arrange
531+
var appBuilder = DistributedApplication.CreateBuilder();
532+
533+
// Set both versions, dash version should take precedence
534+
appBuilder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
535+
{
536+
["Parameters:storage-account-name"] = "dash-value",
537+
["Parameters:storage_account_name"] = "underscore-value"
538+
});
539+
540+
// Act
541+
appBuilder.AddParameter("storage-account-name");
542+
543+
using var app = appBuilder.Build();
544+
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
545+
var parameterResource = Assert.Single(appModel.Resources.OfType<ParameterResource>());
546+
547+
// Assert - should prefer the exact match (with dash)
548+
Assert.Equal("dash-value", await parameterResource.GetValueAsync(default));
549+
}
550+
551+
[Fact]
552+
public async Task ParameterWithoutDash_DoesNotFallbackToUnderscore()
553+
{
554+
// Arrange
555+
var appBuilder = DistributedApplication.CreateBuilder();
556+
557+
// Set only underscore version for a parameter without dash
558+
appBuilder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
559+
{
560+
["Parameters:storage_name"] = "underscore-value"
561+
});
562+
563+
// Act
564+
appBuilder.AddParameter("storagename");
565+
566+
using var app = appBuilder.Build();
567+
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
568+
var parameterResource = Assert.Single(appModel.Resources.OfType<ParameterResource>());
569+
570+
// Assert - should not find the value because names don't match
571+
await Assert.ThrowsAsync<MissingParameterValueException>(async () =>
572+
{
573+
_ = await parameterResource.GetValueAsync(default);
574+
});
575+
}
576+
577+
[Fact]
578+
public async Task ParameterWithCustomConfigurationKey_UsesFallback()
579+
{
580+
// Arrange
581+
var appBuilder = DistributedApplication.CreateBuilder();
582+
583+
// Set configuration with custom key that has underscore
584+
appBuilder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
585+
{
586+
["CustomSection:my_key"] = "custom-value"
587+
});
588+
589+
// Act - use custom configuration key with dash
590+
appBuilder.AddParameterFromConfiguration("my-param", "CustomSection:my-key");
591+
592+
using var app = appBuilder.Build();
593+
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
594+
var parameterResource = Assert.Single(appModel.Resources.OfType<ParameterResource>());
595+
596+
// Assert - should find the value using the normalized key (dash -> underscore)
597+
Assert.Equal("custom-value", await parameterResource.GetValueAsync(default));
598+
}
503599
}

tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,4 +798,77 @@ private static ParameterResource CreateParameterWithGenericError(string name)
798798
{
799799
return new ParameterResource(name, _ => throw new InvalidOperationException($"Generic error for parameter '{name}'"), secret: false);
800800
}
801+
802+
[Fact]
803+
public async Task InitializeParametersAsync_WithGenerateParameterDefaultInPublishMode_ThrowsWhenValueIsEmpty()
804+
{
805+
// Arrange
806+
var configuration = new ConfigurationBuilder().Build();
807+
var services = new ServiceCollection();
808+
services.AddSingleton<IConfiguration>(configuration);
809+
var serviceProvider = services.BuildServiceProvider();
810+
811+
var executionContext = new DistributedApplicationExecutionContext(
812+
new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Publish, "manifest")
813+
{
814+
ServiceProvider = serviceProvider
815+
});
816+
817+
var interactionService = CreateInteractionService();
818+
var parameterProcessor = CreateParameterProcessor(
819+
interactionService: interactionService,
820+
executionContext: executionContext);
821+
822+
var parameterWithGenerateDefault = new ParameterResource(
823+
"generatedParam",
824+
parameterDefault => parameterDefault?.GetDefaultValue() ?? throw new MissingParameterValueException("Parameter 'generatedParam' is missing"),
825+
secret: false)
826+
{
827+
Default = new GenerateParameterDefault()
828+
};
829+
830+
// Act
831+
await parameterProcessor.InitializeParametersAsync([parameterWithGenerateDefault]);
832+
833+
// Assert - Should be added to unresolved parameters because GenerateParameterDefault is not supported in publish mode
834+
Assert.NotNull(parameterWithGenerateDefault.WaitForValueTcs);
835+
Assert.False(parameterWithGenerateDefault.WaitForValueTcs.Task.IsCompleted);
836+
}
837+
838+
[Fact]
839+
public async Task InitializeParametersAsync_WithGenerateParameterDefaultInPublishMode_DoesNotThrowWhenValueExists()
840+
{
841+
// Arrange
842+
var configuration = new ConfigurationBuilder()
843+
.AddInMemoryCollection(new Dictionary<string, string?> { ["Parameters:generatedParam"] = "existingValue" })
844+
.Build();
845+
846+
var services = new ServiceCollection();
847+
services.AddSingleton<IConfiguration>(configuration);
848+
var serviceProvider = services.BuildServiceProvider();
849+
850+
var executionContext = new DistributedApplicationExecutionContext(
851+
new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Publish, "manifest")
852+
{
853+
ServiceProvider = serviceProvider
854+
});
855+
856+
var parameterProcessor = CreateParameterProcessor(executionContext: executionContext);
857+
858+
var parameterWithGenerateDefault = new ParameterResource(
859+
"generatedParam",
860+
parameterDefault => configuration["Parameters:generatedParam"] ?? parameterDefault?.GetDefaultValue() ?? throw new MissingParameterValueException("Parameter 'generatedParam' is missing"),
861+
secret: false)
862+
{
863+
Default = new GenerateParameterDefault()
864+
};
865+
866+
// Act
867+
await parameterProcessor.InitializeParametersAsync([parameterWithGenerateDefault]);
868+
869+
// Assert - Should succeed because value exists in configuration
870+
Assert.NotNull(parameterWithGenerateDefault.WaitForValueTcs);
871+
Assert.True(parameterWithGenerateDefault.WaitForValueTcs.Task.IsCompletedSuccessfully);
872+
Assert.Equal("existingValue", await parameterWithGenerateDefault.WaitForValueTcs.Task);
873+
}
801874
}

0 commit comments

Comments
 (0)