From 1184fc5cab67f862604df4f1ded058a215ad62bd Mon Sep 17 00:00:00 2001 From: Arad Alvand Date: Tue, 9 Jun 2026 01:27:00 +0330 Subject: [PATCH 1/5] add `WithModule` extension methods to `IResourceBuilder` --- .../RedisBuilderExtensions.cs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs index 8d8c47dc0e8..041210e7121 100644 --- a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs +++ b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs @@ -128,6 +128,15 @@ public static IResourceBuilder AddRedis( additionalArgs.Add(persistenceAnnotation.KeysChangedThreshold.ToString(CultureInfo.InvariantCulture)); } + if (redis.TryGetAnnotationsOfType(out var moduleAnnotations)) + { + foreach (var moduleAnnotation in moduleAnnotations) + { + additionalArgs.Add("--loadmodule"); + additionalArgs.Add(moduleAnnotation.Path); + } + } + // This is a temporary workaround to allow the args list to be expanded dynamically at run time with additional server certificate arguments. if (context.ExecutionContext.IsRunMode) { @@ -520,6 +529,40 @@ private sealed class PersistenceAnnotation(TimeSpan? interval, long keysChangedT public long KeysChangedThreshold => keysChangedThreshold; } + /// + /// Configures the Redis resource to use the specified native Redis module. + /// + /// The resource builder. + /// The well-known, pre-installed Redis module to load. + /// The . + [AspireExport] + public static IResourceBuilder WithModule(this IResourceBuilder builder, RedisNativeModule nativeModule) => + builder.WithAnnotation( + annotation: new RedisNativeModuleAnnotation(nativeModule), + behavior: ResourceAnnotationMutationBehavior.Append + ); + + /// + /// Configures the Redis resource to use the specified Redis module by providing the path to the module's .so file on the container. + /// + /// The resource builder. + /// The path to the Redis module .so file on the container. This should be an absolute path. + /// The . + [AspireExport] + public static IResourceBuilder WithModule(this IResourceBuilder builder, string path) => + builder.WithAnnotation( + annotation: new RedisModuleAnnotation(path), + behavior: ResourceAnnotationMutationBehavior.Append + ); + + private record RedisModuleAnnotation( + string Path + ) : IResourceAnnotation; + + private sealed record RedisNativeModuleAnnotation( + RedisNativeModule NativeModule + ) : RedisModuleAnnotation(NativeModule.Path); + /// /// Adds a named volume for the data folder to a Redis Insight container resource. /// @@ -586,3 +629,47 @@ public static IResourceBuilder WithHostPort(this IResourceBuilder } } + +/// +/// Well-known Redis modules that are included in the Redis container image from version 8 and above. +/// +/// +/// See https://redis.io/blog/redis-8-ga/ +/// +public enum RedisNativeModule +{ + /// + /// Redis JSON module for storing, updating, and querying JSON documents in Redis. + /// + Json, + + /// + /// Redis Search module for secondary indexing and querying of data stored in Redis. + /// + Search, + + /// + /// Redis Bloom Filter module for probabilistic data structures including Bloom filters, Cuckoo filters, Count-Min Sketches, and Top-K filters. + /// + BloomFilter, + + /// + /// Redis TimeSeries module for efficient storage and querying of time series data in Redis. + /// + TimeSeries, +} + +internal static class RedisNativeModuleExtensions +{ + extension(RedisNativeModule nativeModule) + { + public string Path => nativeModule switch + { + RedisNativeModule.Search => "/usr/local/lib/redis/modules/redisearch.so", + RedisNativeModule.Json => "/usr/local/lib/redis/modules/rejson.so", + RedisNativeModule.BloomFilter => "/usr/local/lib/redis/modules/redisbloom.so", + RedisNativeModule.TimeSeries => "/usr/local/lib/redis/modules/redistimeseries.so", + _ => throw new NotSupportedException($"The Redis native module '{nativeModule}' is not supported."), + }; + } +} From afe1cdfeb07144903adaa0a072ae36e510d97757 Mon Sep 17 00:00:00 2001 From: Arad Alvand Date: Tue, 9 Jun 2026 01:35:01 +0330 Subject: [PATCH 2/5] simplify `WithAnnotation` calls --- src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs index 041210e7121..f02e48eb29b 100644 --- a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs +++ b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs @@ -537,10 +537,7 @@ private sealed class PersistenceAnnotation(TimeSpan? interval, long keysChangedT /// The . [AspireExport] public static IResourceBuilder WithModule(this IResourceBuilder builder, RedisNativeModule nativeModule) => - builder.WithAnnotation( - annotation: new RedisNativeModuleAnnotation(nativeModule), - behavior: ResourceAnnotationMutationBehavior.Append - ); + builder.WithAnnotation(new RedisNativeModuleAnnotation(nativeModule)); /// /// Configures the Redis resource to use the specified Redis module by providing the path to the module's .so file on the container. @@ -550,10 +547,7 @@ public static IResourceBuilder WithModule(this IResourceBuilderThe . [AspireExport] public static IResourceBuilder WithModule(this IResourceBuilder builder, string path) => - builder.WithAnnotation( - annotation: new RedisModuleAnnotation(path), - behavior: ResourceAnnotationMutationBehavior.Append - ); + builder.WithAnnotation(new RedisModuleAnnotation(path)); private record RedisModuleAnnotation( string Path @@ -631,7 +625,7 @@ public static IResourceBuilder WithHostPort(this IResourceBuilder } /// -/// Well-known Redis modules that are included in the Redis container image from version 8 and above. +/// Well-known Redis modules that are included in the Redis container image from version 8 onward. /// /// /// See https://redis.io/blog/redis-8-ga/ From ef84185e28ee82732fa8318ef876e02545dd034b Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 9 Jun 2026 09:54:27 -0700 Subject: [PATCH 3/5] Fix Redis WithModule polyglot export Expose a single polyglot-only union overload for Redis WithModule, keep typed C# overloads public, and add test/polyglot coverage for the new API. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.Redis/AssemblyInfo.cs | 6 ++ .../RedisBuilderExtensions.cs | 99 +++++++++---------- src/Aspire.Hosting.Redis/RedisNativeModule.cs | 33 +++++++ .../AddRedisTests.cs | 37 +++++++ .../RedisPublicApiTests.cs | 54 ++++++++++ .../Aspire.Hosting.Redis/Go/apphost.go | 7 ++ .../Aspire.Hosting.Redis/Java/AppHost.java | 3 + .../Aspire.Hosting.Redis/Python/apphost.py | 3 + .../TypeScript/apphost.mts | 6 +- 9 files changed, 193 insertions(+), 55 deletions(-) create mode 100644 src/Aspire.Hosting.Redis/AssemblyInfo.cs create mode 100644 src/Aspire.Hosting.Redis/RedisNativeModule.cs diff --git a/src/Aspire.Hosting.Redis/AssemblyInfo.cs b/src/Aspire.Hosting.Redis/AssemblyInfo.cs new file mode 100644 index 00000000000..ec4567dd0d3 --- /dev/null +++ b/src/Aspire.Hosting.Redis/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Aspire.Hosting.Redis.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001004b86c4cb78549b34bab61a3b1800e23bfeb5b3ec390074041536a7e3cbd97f5f04cf0f857155a8928eaa29ebfd11cfbbad3ba70efea7bda3226c6a8d370a4cd303f714486b6ebc225985a638471e6ef571cc92a4613c00b8fa65d61ccee0cbe5f36330c9a01f4183559f1bef24cc2917c6d913e3a541333a1d05d9bed22b38cb")] diff --git a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs index f02e48eb29b..67c64a5b837 100644 --- a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs +++ b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs @@ -533,11 +533,45 @@ private sealed class PersistenceAnnotation(TimeSpan? interval, long keysChangedT /// Configures the Redis resource to use the specified native Redis module. /// /// The resource builder. - /// The well-known, pre-installed Redis module to load. + /// The well-known, pre-installed Redis module to load, or the path to the Redis module .so file on the container. /// The . + /// The resource builder. [AspireExport] - public static IResourceBuilder WithModule(this IResourceBuilder builder, RedisNativeModule nativeModule) => - builder.WithAnnotation(new RedisNativeModuleAnnotation(nativeModule)); + internal static IResourceBuilder WithModule(this IResourceBuilder builder, [AspireUnion(typeof(RedisNativeModule), typeof(string))] object module) + { + ArgumentNullException.ThrowIfNull(builder); + + return module switch + { + RedisNativeModule nativeModule => builder.WithModule(nativeModule), + string path => builder.WithModule(path), + null => throw new ArgumentNullException(nameof(module)), + _ => throw new ArgumentException($"Unsupported Redis module type '{module.GetType()}'.", nameof(module)), + }; + } + + /// + /// Configures the Redis resource to use the specified native Redis module. + /// + /// The resource builder. + /// The well-known, pre-installed Redis module to load. + /// The . + [AspireExportIgnore(Reason = "Polyglot app hosts use the canonical withModule export with a RedisNativeModule|string union.")] + public static IResourceBuilder WithModule(this IResourceBuilder builder, RedisNativeModule nativeModule) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.WithAnnotation(new RedisModuleAnnotation(GetModulePath(nativeModule))); + + static string GetModulePath(RedisNativeModule nativeModule) => nativeModule switch + { + RedisNativeModule.Search => "/usr/local/lib/redis/modules/redisearch.so", + RedisNativeModule.Json => "/usr/local/lib/redis/modules/rejson.so", + RedisNativeModule.BloomFilter => "/usr/local/lib/redis/modules/redisbloom.so", + RedisNativeModule.TimeSeries => "/usr/local/lib/redis/modules/redistimeseries.so", + _ => throw new NotSupportedException($"The Redis native module '{nativeModule}' is not supported."), + }; + } /// /// Configures the Redis resource to use the specified Redis module by providing the path to the module's .so file on the container. @@ -545,18 +579,19 @@ public static IResourceBuilder WithModule(this IResourceBuilderThe resource builder. /// The path to the Redis module .so file on the container. This should be an absolute path. /// The . - [AspireExport] - public static IResourceBuilder WithModule(this IResourceBuilder builder, string path) => - builder.WithAnnotation(new RedisModuleAnnotation(path)); + [AspireExportIgnore(Reason = "Polyglot app hosts use the canonical withModule export with a RedisNativeModule|string union.")] + public static IResourceBuilder WithModule(this IResourceBuilder builder, string path) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(path); + + return builder.WithAnnotation(new RedisModuleAnnotation(path)); + } private record RedisModuleAnnotation( string Path ) : IResourceAnnotation; - private sealed record RedisNativeModuleAnnotation( - RedisNativeModule NativeModule - ) : RedisModuleAnnotation(NativeModule.Path); - /// /// Adds a named volume for the data folder to a Redis Insight container resource. /// @@ -623,47 +658,3 @@ public static IResourceBuilder WithHostPort(this IResourceBuilder } } - -/// -/// Well-known Redis modules that are included in the Redis container image from version 8 onward. -/// -/// -/// See https://redis.io/blog/redis-8-ga/ -/// -public enum RedisNativeModule -{ - /// - /// Redis JSON module for storing, updating, and querying JSON documents in Redis. - /// - Json, - - /// - /// Redis Search module for secondary indexing and querying of data stored in Redis. - /// - Search, - - /// - /// Redis Bloom Filter module for probabilistic data structures including Bloom filters, Cuckoo filters, Count-Min Sketches, and Top-K filters. - /// - BloomFilter, - - /// - /// Redis TimeSeries module for efficient storage and querying of time series data in Redis. - /// - TimeSeries, -} - -internal static class RedisNativeModuleExtensions -{ - extension(RedisNativeModule nativeModule) - { - public string Path => nativeModule switch - { - RedisNativeModule.Search => "/usr/local/lib/redis/modules/redisearch.so", - RedisNativeModule.Json => "/usr/local/lib/redis/modules/rejson.so", - RedisNativeModule.BloomFilter => "/usr/local/lib/redis/modules/redisbloom.so", - RedisNativeModule.TimeSeries => "/usr/local/lib/redis/modules/redistimeseries.so", - _ => throw new NotSupportedException($"The Redis native module '{nativeModule}' is not supported."), - }; - } -} diff --git a/src/Aspire.Hosting.Redis/RedisNativeModule.cs b/src/Aspire.Hosting.Redis/RedisNativeModule.cs new file mode 100644 index 00000000000..c12eeb62276 --- /dev/null +++ b/src/Aspire.Hosting.Redis/RedisNativeModule.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting; + +/// +/// Well-known Redis modules that are included in the Redis container image from version 8 onward. +/// +/// +/// See https://redis.io/blog/redis-8-ga/. +/// +public enum RedisNativeModule +{ + /// + /// Redis JSON module for storing, updating, and querying JSON documents in Redis. + /// + Json, + + /// + /// Redis Search module for secondary indexing and querying of data stored in Redis. + /// + Search, + + /// + /// Redis Bloom Filter module for probabilistic data structures including Bloom filters, Cuckoo filters, Count-Min Sketches, and Top-K filters. + /// + BloomFilter, + + /// + /// Redis TimeSeries module for efficient storage and querying of time series data in Redis. + /// + TimeSeries, +} diff --git a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs index c057d678283..e9693a748cb 100644 --- a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs @@ -656,6 +656,43 @@ public async Task WithPersistenceReplacesPreviousAnnotationInstances() Assert.DoesNotContain("--save", args.Substring(saveIndex + 1)); } + [Fact] + public async Task WithModuleAddsCommandLineArgsForNativeModule() + { + using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + var redis = builder.AddRedis("myRedis") + .WithModule(RedisNativeModule.Json); + + var args = await GetCommandLineArgs(redis); + + Assert.Contains("--loadmodule /usr/local/lib/redis/modules/rejson.so", args); + } + + [Fact] + public async Task WithModuleAddsCommandLineArgsForModulePath() + { + using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + var redis = builder.AddRedis("myRedis") + .WithModule("/opt/redis/custom-module.so"); + + var args = await GetCommandLineArgs(redis); + + Assert.Contains("--loadmodule /opt/redis/custom-module.so", args); + } + + [Fact] + public async Task WithModuleAddsCommandLineArgsForMultipleModules() + { + using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + var redis = builder.AddRedis("myRedis") + .WithModule(RedisNativeModule.Search) + .WithModule("/opt/redis/custom-module.so"); + + var args = await GetCommandLineArgs(redis); + + Assert.Contains("--loadmodule /usr/local/lib/redis/modules/redisearch.so --loadmodule /opt/redis/custom-module.so", args); + } + private static async Task GetCommandLineArgs(IResourceBuilder builder) { var args = await ArgumentEvaluator.GetArgumentListAsync(builder.Resource); diff --git a/tests/Aspire.Hosting.Redis.Tests/RedisPublicApiTests.cs b/tests/Aspire.Hosting.Redis.Tests/RedisPublicApiTests.cs index fcbbbeb8fd7..ca01e91fcc7 100644 --- a/tests/Aspire.Hosting.Redis.Tests/RedisPublicApiTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/RedisPublicApiTests.cs @@ -133,6 +133,60 @@ public void WithPersistenceShouldThrowWhenBuilderIsNull() Assert.Equal(nameof(builder), exception.ParamName); } + [Fact] + public void WithModuleShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + var action = () => builder.WithModule(RedisNativeModule.Json); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void WithModuleShouldThrowWhenPathIsNullOrEmpty(bool isNull) + { + var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + var redis = builder.AddRedis("Redis"); + var path = isNull ? null! : string.Empty; + + var action = () => redis.WithModule(path); + + var exception = isNull + ? Assert.Throws(action) + : Assert.Throws(action); + Assert.Equal(nameof(path), exception.ParamName); + } + + [Fact] + public void WithModuleUnionShouldThrowWhenModuleIsNull() + { + var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + var redis = builder.AddRedis("Redis"); + object module = null!; + + var action = () => redis.WithModule(module); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(module), exception.ParamName); + } + + [Fact] + public void WithModuleUnionShouldThrowWhenModuleTypeIsUnsupported() + { + var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + var redis = builder.AddRedis("Redis"); + object module = 1; + + var action = () => redis.WithModule(module); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(module), exception.ParamName); + } + [Fact] public void RedisInsightWithDataVolumeShouldThrowWhenBuilderIsNull() { diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Go/apphost.go b/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Go/apphost.go index 685dad0dfca..8f75b258b78 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Go/apphost.go +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Go/apphost.go @@ -39,6 +39,13 @@ func main() { log.Fatalf(aspire.FormatError(err)) } + // withModule on RedisResource — AspireUnion supports native modules and custom module paths + cache.WithModule(aspire.RedisNativeModuleJson) + cache.WithModule("/opt/redis/custom-module.so") + if err = cache.Err(); err != nil { + log.Fatalf(aspire.FormatError(err)) + } + // withHostPort on cache — stand-alone cache.WithHostPort(6379) if err = cache.Err(); err != nil { diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Java/AppHost.java b/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Java/AppHost.java index fd60eed28b1..906a026d6c7 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Java/AppHost.java +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Java/AppHost.java @@ -12,6 +12,9 @@ void main() throws Exception { cache.withPersistence(new WithPersistenceOptions().interval(600000000.0).keysChangedThreshold(5.0)); // withDataBindMount on RedisResource cache2.withDataBindMount("/tmp/redis-data"); + // withModule on RedisResource - AspireUnion supports native modules and custom module paths + cache.withModule(RedisNativeModule.JSON); + cache.withModule("/opt/redis/custom-module.so"); // withHostPort on RedisResource cache.withHostPort(6379.0); // withPassword on RedisResource diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Python/apphost.py b/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Python/apphost.py index 04c1a7a1d2e..534603870b8 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Python/apphost.py +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Python/apphost.py @@ -15,6 +15,9 @@ cache.with_persistence() # withDataBindMount on RedisResource cache2.with_data_bind_mount() + # withModule on RedisResource - AspireUnion supports native modules and custom module paths + cache.with_module("Json") + cache.with_module("/opt/redis/custom-module.so") # withHostPort on RedisResource cache.with_host_port() # withPassword on RedisResource diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Redis/TypeScript/apphost.mts b/tests/PolyglotAppHosts/Aspire.Hosting.Redis/TypeScript/apphost.mts index 8900fca4956..cbb814bac84 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Redis/TypeScript/apphost.mts +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Redis/TypeScript/apphost.mts @@ -1,4 +1,4 @@ -import { createBuilder } from './.aspire/modules/aspire.mjs'; +import { createBuilder, RedisNativeModule } from './.aspire/modules/aspire.mjs'; const builder = await createBuilder(); @@ -16,6 +16,10 @@ await cache.withPersistence({ interval: 600000000, keysChangedThreshold: 5 }); // withDataBindMount on RedisResource await cache2.withDataBindMount("/tmp/redis-data"); +// withModule on RedisResource — AspireUnion supports native modules and custom module paths +await cache.withModule(RedisNativeModule.Json); +await cache.withModule("/opt/redis/custom-module.so"); + // withHostPort on RedisResource await cache.withHostPort({ port: 6379 }); From d6888a6ab1cee8455993e34aec9e25b9cad5b55c Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 9 Jun 2026 10:22:18 -0700 Subject: [PATCH 4/5] Validate Redis module paths Reject custom Redis module paths unless they are absolute container paths ending in .so, and cover invalid values in unit tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../RedisBuilderExtensions.cs | 9 +++++++++ .../RedisPublicApiTests.cs | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs index 67c64a5b837..3f215cbbded 100644 --- a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs +++ b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs @@ -584,6 +584,15 @@ public static IResourceBuilder WithModule(this IResourceBuilder redis.WithModule(path); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(path), exception.ParamName); + } + [Fact] public void WithModuleUnionShouldThrowWhenModuleIsNull() { From be3eeb09891be487ac28432f6a34ba9c8d84f212 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 9 Jun 2026 11:51:28 -0700 Subject: [PATCH 5/5] Collect enum types from ATS unions Ensure enums referenced only through AspireUnion alternatives are included in the scanned enum type set so polyglot SDK generation can emit them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AtsCapabilityScanner.cs | 8 ++++++ .../AtsCapabilityScannerTests.cs | 28 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs b/src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs index 55fc81446c9..bc0cf018c82 100644 --- a/src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs +++ b/src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs @@ -616,6 +616,14 @@ private static void CollectEnumClrTypes(AtsTypeRef? typeRef, Dictionary diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.cs index 9ceccda0849..573dd4cb2f5 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.cs @@ -165,6 +165,21 @@ public void ScanAssembly_IEnumerableCapability_UsesArrayTypes() Assert.Equal(AtsTypeCategory.Array, enumerableReturnCapability.ReturnType.Category); } + [Fact] + public void ScanAssembly_UnionCapability_CollectsEnumTypesFromUnionMembers() + { + var result = AtsCapabilityScanner.ScanAssembly(typeof(AtsCapabilityScannerTests).Assembly); + + var capability = Assert.Single(result.Capabilities, + c => c.CapabilityId.EndsWith("/testUnionEnumParameter", StringComparison.Ordinal)); + var parameter = Assert.Single(capability.Parameters); + + Assert.Equal(AtsTypeCategory.Union, parameter.Type?.Category); + Assert.Contains(parameter.Type!.UnionTypes!, type => type.ClrType == typeof(TestUnionEnum)); + Assert.Contains(parameter.Type.UnionTypes!, type => type.TypeId == AtsConstants.String); + Assert.Contains(result.EnumTypes, type => type.ClrType == typeof(TestUnionEnum)); + } + #endregion #region DeriveMethodName Tests @@ -723,6 +738,12 @@ private sealed class ShadowedEnvironmentResource(string name) : Resource(name), private sealed class OtherEnvironmentResource(string name) : Resource(name), IResourceWithEnvironment; + private enum TestUnionEnum + { + First, + Second + } + [AspireDto] private sealed class NullableScalarDto { @@ -786,6 +807,13 @@ public static IResourceBuilder TestMultiParamHandleCallback( return builder; } + [AspireExport] + public static void TestUnionEnumParameter(IDistributedApplicationBuilder builder, [AspireUnion(typeof(TestUnionEnum), typeof(string))] object value) + { + _ = builder; + _ = value; + } + /// The fallback value. [AspireExport("descriptionFallback", Description = "Uses the description as fallback documentation.")] public static void DescriptionFallback(IDistributedApplicationBuilder builder, string value)