From 05d225c5220f4d1d3c8ddea2b9836e2711ad0875 Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Fri, 23 Oct 2026 14:11:48 +0300 Subject: [PATCH 1/2] feat: create clients through the AWS SDK static factories on .NET 10+ CreateClientByInterface() is what every AddAwsService/TryAdd* overload calls. When the session uses the default SessionReflection, it now creates the config and the client with TClient.CreateDefaultClientConfig() and TClient.CreateDefaultServiceClient(), looks the endpoint up by ClientConfig.ServiceId (equal to the private serviceMetadata.ServiceId for every pinned AWS service), and sets S3's ForcePathStyle through a string-named UnsafeAccessor, so Core needs no AWSSDK.S3 reference. No reflection and no source generator on this path. Behaviour is unchanged: the same validation order and messages, the same config values, and client construction failures still surface as TargetInvocationException with the original inner exception. A custom or mocked ISessionReflection keeps today's path call for call. The mock static factories now pass on the configuration they receive, and MockClientConfig carries the mock service id. --- src/LocalStack.Client/Session.cs | 48 ++++++++++++++++++ .../Utils/S3ForcePathStyle.cs | 22 ++++++++ .../Utils/SessionReflection.cs | 6 +++ .../CreateClientByInterfaceTests.cs | 12 +++++ .../SessionTests/SessionStaticFactoryTests.cs | 50 +++++++++++++++++++ .../FactoryCreatedMockClient.cs | 9 ++++ .../MockServiceClients/IMockAmazonService.cs | 4 +- .../IMockAmazonServiceWithServiceMetadata.cs | 4 +- .../IMockFactoryAmazonService.cs | 16 ++++++ .../IMockThrowingFactoryAmazonService.cs | 14 ++++++ .../MockAmazonServiceClient.cs | 4 +- ...kAmazonServiceWithServiceMetadataClient.cs | 4 +- .../MockServiceClients/MockClientConfig.cs | 3 +- 13 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 src/LocalStack.Client/Utils/S3ForcePathStyle.cs create mode 100644 tests/LocalStack.Client.Tests/SessionTests/SessionStaticFactoryTests.cs create mode 100644 tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/FactoryCreatedMockClient.cs create mode 100644 tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/IMockFactoryAmazonService.cs create mode 100644 tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/IMockThrowingFactoryAmazonService.cs diff --git a/src/LocalStack.Client/Session.cs b/src/LocalStack.Client/Session.cs index b959ce6..f9772bb 100644 --- a/src/LocalStack.Client/Session.cs +++ b/src/LocalStack.Client/Session.cs @@ -63,6 +63,12 @@ public AmazonServiceClient CreateClientByImplementation( public AmazonServiceClient CreateClientByInterface(bool useServiceUrl = false) where TClient : IAmazonService { +#if NET10_0_OR_GREATER + if (_sessionReflection.GetType() == typeof(SessionReflection)) + { + return CreateClientByStaticFactory(useServiceUrl); + } +#endif Type serviceInterfaceType = typeof(TClient); return CreateClientByInterface(serviceInterfaceType, useServiceUrl); @@ -127,6 +133,48 @@ public AmazonServiceClient CreateClientByInterface( return client; } +#if NET10_0_OR_GREATER + private AmazonServiceClient CreateClientByStaticFactory(bool useServiceUrl) where TClient : IAmazonService + { + if (!useServiceUrl && string.IsNullOrWhiteSpace(_sessionOptions.RegionName)) + { + throw new MisconfiguredClientException($"{nameof(_sessionOptions.RegionName)} must be set if {nameof(useServiceUrl)} is false."); + } + + ClientConfig clientConfig = TClient.CreateDefaultClientConfig(); + string serviceId = clientConfig.ServiceId; + AwsServiceEndpoint awsServiceEndpoint = _config.GetAwsServiceEndpoint(serviceId) ?? + throw new NotSupportedClientException($"{serviceId} is not supported by this mock session."); + AWSCredentials awsCredentials = new SessionAWSCredentials(_sessionOptions.AwsAccessKeyId, _sessionOptions.AwsAccessKey, _sessionOptions.AwsSessionToken); + + clientConfig.UseHttp = !_config.GetConfigOptions().UseSsl; + S3ForcePathStyle.TrySet(clientConfig, true); + clientConfig.ProxyHost = awsServiceEndpoint.Host; + clientConfig.ProxyPort = awsServiceEndpoint.Port; + + if (useServiceUrl) + { + clientConfig.ServiceURL = awsServiceEndpoint.ServiceUrl.AbsoluteUri; + } + else if (!string.IsNullOrWhiteSpace(_sessionOptions.RegionName)) + { + clientConfig.RegionEndpoint = RegionEndpoint.GetBySystemName(_sessionOptions.RegionName); + } + + IAmazonService client; + try + { + client = TClient.CreateDefaultServiceClient(awsCredentials, clientConfig); + } + catch (Exception exception) + { + throw new TargetInvocationException(exception); + } + + return (AmazonServiceClient)client; + } +#endif + private static Type? FindClientType(Assembly assembly, string name) { #if NET8_0_OR_GREATER diff --git a/src/LocalStack.Client/Utils/S3ForcePathStyle.cs b/src/LocalStack.Client/Utils/S3ForcePathStyle.cs new file mode 100644 index 0000000..083d561 --- /dev/null +++ b/src/LocalStack.Client/Utils/S3ForcePathStyle.cs @@ -0,0 +1,22 @@ +#if NET10_0_OR_GREATER +using System.Runtime.CompilerServices; + +namespace LocalStack.Client.Utils; + +internal static class S3ForcePathStyle +{ + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_ForcePathStyle")] + private static extern void SetForcePathStyle([UnsafeAccessorType("Amazon.S3.AmazonS3Config, AWSSDK.S3")] object config, bool value); + + internal static bool TrySet(ClientConfig clientConfig, bool value) + { + if (string.Equals(clientConfig.ServiceId, "S3", StringComparison.Ordinal)) + { + SetForcePathStyle(clientConfig, value); + return true; + } + + return false; + } +} +#endif diff --git a/src/LocalStack.Client/Utils/SessionReflection.cs b/src/LocalStack.Client/Utils/SessionReflection.cs index 09fa78f..81f2060 100644 --- a/src/LocalStack.Client/Utils/SessionReflection.cs +++ b/src/LocalStack.Client/Utils/SessionReflection.cs @@ -124,6 +124,12 @@ public bool SetForcePathStyle(ClientConfig clientConfig, bool value = true) throw new ArgumentNullException(nameof(clientConfig)); } +#if NET10_0_OR_GREATER + if (S3ForcePathStyle.TrySet(clientConfig, value)) + { + return true; + } +#endif #if NET8_0_OR_GREATER if (GeneratedClientRegistry.TryGetConfig(clientConfig.GetType(), out GeneratedClientRegistry.ConfigEntry? entry)) { diff --git a/tests/LocalStack.Client.Integration.Tests/CreateClientByInterfaceTests.cs b/tests/LocalStack.Client.Integration.Tests/CreateClientByInterfaceTests.cs index fbfdaf4..1c87d88 100644 --- a/tests/LocalStack.Client.Integration.Tests/CreateClientByInterfaceTests.cs +++ b/tests/LocalStack.Client.Integration.Tests/CreateClientByInterfaceTests.cs @@ -11,6 +11,18 @@ static CreateClientByInterfaceTests() { } + [Fact] + public void Force_path_style_sets_s3_config_and_returns_false_for_sqs_config() + { + var reflection = new Utils.SessionReflection(); + var s3Config = new AmazonS3Config(); + + Assert.False(s3Config.ForcePathStyle); + Assert.True(reflection.SetForcePathStyle(s3Config)); + Assert.True(s3Config.ForcePathStyle); + Assert.False(reflection.SetForcePathStyle(new AmazonSQSConfig())); + } + [Fact] public void Should_Able_To_Create_AmazonAPIGatewayClient() { diff --git a/tests/LocalStack.Client.Tests/SessionTests/SessionStaticFactoryTests.cs b/tests/LocalStack.Client.Tests/SessionTests/SessionStaticFactoryTests.cs new file mode 100644 index 0000000..5613474 --- /dev/null +++ b/tests/LocalStack.Client.Tests/SessionTests/SessionStaticFactoryTests.cs @@ -0,0 +1,50 @@ +#if NET10_0_OR_GREATER +namespace LocalStack.Client.Tests.SessionTests; + +public class SessionStaticFactoryTests +{ + [Fact] + public void Default_reflection_uses_static_factory_without_client_naming_convention() + { + Session session = CreateSession(); + + using AmazonServiceClient client = session.CreateClientByInterface(); + + Assert.IsType(client); + Assert.True(client.Config.UseHttp); + Assert.Equal("localhost", client.Config.ProxyHost); + Assert.Equal(1234, client.Config.ProxyPort); + Assert.Equal(RegionEndpoint.EUWest2, client.Config.RegionEndpoint); + } + + [Fact] + public void Default_reflection_wraps_static_factory_failure_preserving_exception_identity() + { + Session session = CreateSession(); + + TargetInvocationException exception = Assert.Throws(() => session.CreateClientByInterface()); + + Assert.Same(IMockThrowingFactoryAmazonService.Failure, exception.InnerException); + } + + [Fact] + public void Static_factory_receives_the_configured_instance_with_service_url() + { + Session session = CreateSession(); + FactoryCreatedMockClient.RecordedConfig = null; + + using AmazonServiceClient client = session.CreateClientByInterface(useServiceUrl: true); + + Assert.Same(FactoryCreatedMockClient.RecordedConfig, client.Config); + Assert.Equal("http://localhost:1234/", client.Config.ServiceURL); + } + + private static Session CreateSession() + { + var config = new Mock(MockBehavior.Strict); + config.Setup(value => value.GetAwsServiceEndpoint(MockServiceMetadata.MockServiceId)).Returns(new MockAwsServiceEndpoint()); + config.Setup(value => value.GetConfigOptions()).Returns(new ConfigOptions()); + return new Session(new SessionOptions(regionName: "eu-west-2"), config.Object, new SessionReflection()); + } +} +#endif diff --git a/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/FactoryCreatedMockClient.cs b/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/FactoryCreatedMockClient.cs new file mode 100644 index 0000000..1cc96eb --- /dev/null +++ b/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/FactoryCreatedMockClient.cs @@ -0,0 +1,9 @@ +#if NET10_0_OR_GREATER +namespace LocalStack.Tests.Common.Mocks.MockServiceClients; + +public sealed class FactoryCreatedMockClient(AWSCredentials credentials, MockClientConfig clientConfig) + : MockAmazonServiceClient(credentials, clientConfig), IMockFactoryAmazonService +{ + public static ClientConfig? RecordedConfig { get; set; } +} +#endif diff --git a/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/IMockAmazonService.cs b/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/IMockAmazonService.cs index d5c2a9c..dd95a3e 100644 --- a/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/IMockAmazonService.cs +++ b/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/IMockAmazonService.cs @@ -8,8 +8,8 @@ public interface IMockAmazonService : IDisposable, IAmazonService static IAmazonService IAmazonService.CreateDefaultServiceClient(AWSCredentials awsCredentials, ClientConfig clientConfig) { - return new MockAmazonServiceClient(awsCredentials, MockClientConfig.CreateDefaultMockClientConfig()); + return new MockAmazonServiceClient(awsCredentials, (MockClientConfig)clientConfig); } #pragma warning restore CA1033 #endif -} \ No newline at end of file +} diff --git a/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/IMockAmazonServiceWithServiceMetadata.cs b/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/IMockAmazonServiceWithServiceMetadata.cs index c1dbf29..aa970a6 100644 --- a/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/IMockAmazonServiceWithServiceMetadata.cs +++ b/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/IMockAmazonServiceWithServiceMetadata.cs @@ -8,8 +8,8 @@ public interface IMockAmazonServiceWithServiceMetadata : IDisposable, IAmazonSer static IAmazonService IAmazonService.CreateDefaultServiceClient(AWSCredentials awsCredentials, ClientConfig clientConfig) { - return new MockAmazonServiceWithServiceMetadataClient(awsCredentials, MockClientConfig.CreateDefaultMockClientConfig()); + return new MockAmazonServiceWithServiceMetadataClient(awsCredentials, (MockClientConfig)clientConfig); } #pragma warning restore CA1033 #endif -} \ No newline at end of file +} diff --git a/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/IMockFactoryAmazonService.cs b/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/IMockFactoryAmazonService.cs new file mode 100644 index 0000000..f8481c5 --- /dev/null +++ b/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/IMockFactoryAmazonService.cs @@ -0,0 +1,16 @@ +#if NET10_0_OR_GREATER +namespace LocalStack.Tests.Common.Mocks.MockServiceClients; + +public interface IMockFactoryAmazonService : IAmazonService +{ +#pragma warning disable CA1033 + static ClientConfig IAmazonService.CreateDefaultClientConfig() => MockClientConfig.CreateDefaultMockClientConfig(); + + static IAmazonService IAmazonService.CreateDefaultServiceClient(AWSCredentials awsCredentials, ClientConfig clientConfig) + { + FactoryCreatedMockClient.RecordedConfig = clientConfig; + return new FactoryCreatedMockClient(awsCredentials, (MockClientConfig)clientConfig); + } +#pragma warning restore CA1033 +} +#endif diff --git a/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/IMockThrowingFactoryAmazonService.cs b/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/IMockThrowingFactoryAmazonService.cs new file mode 100644 index 0000000..402f9f7 --- /dev/null +++ b/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/IMockThrowingFactoryAmazonService.cs @@ -0,0 +1,14 @@ +#if NET10_0_OR_GREATER +namespace LocalStack.Tests.Common.Mocks.MockServiceClients; + +public interface IMockThrowingFactoryAmazonService : IAmazonService +{ + static InvalidOperationException Failure { get; } = new("fixture static factory failure"); + +#pragma warning disable CA1033 + static ClientConfig IAmazonService.CreateDefaultClientConfig() => MockClientConfig.CreateDefaultMockClientConfig(); + + static IAmazonService IAmazonService.CreateDefaultServiceClient(AWSCredentials awsCredentials, ClientConfig clientConfig) => throw Failure; +#pragma warning restore CA1033 +} +#endif diff --git a/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/MockAmazonServiceClient.cs b/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/MockAmazonServiceClient.cs index 5eeb1f7..5693c26 100644 --- a/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/MockAmazonServiceClient.cs +++ b/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/MockAmazonServiceClient.cs @@ -34,7 +34,7 @@ public static ClientConfig CreateDefaultClientConfig() public static IAmazonService CreateDefaultServiceClient(AWSCredentials awsCredentials, ClientConfig clientConfig) { - return new MockAmazonServiceClient(awsCredentials, MockClientConfig.CreateDefaultMockClientConfig()); + return new MockAmazonServiceClient(awsCredentials, (MockClientConfig)clientConfig); } #endif -} \ No newline at end of file +} diff --git a/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/MockAmazonServiceWithServiceMetadataClient.cs b/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/MockAmazonServiceWithServiceMetadataClient.cs index 05d93b0..2f17ca5 100644 --- a/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/MockAmazonServiceWithServiceMetadataClient.cs +++ b/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/MockAmazonServiceWithServiceMetadataClient.cs @@ -33,7 +33,7 @@ public static ClientConfig CreateDefaultClientConfig() public static IAmazonService CreateDefaultServiceClient(AWSCredentials awsCredentials, ClientConfig clientConfig) { - return new MockAmazonServiceWithServiceMetadataClient(awsCredentials, MockClientConfig.CreateDefaultMockClientConfig()); + return new MockAmazonServiceWithServiceMetadataClient(awsCredentials, (MockClientConfig)clientConfig); } #endif -} \ No newline at end of file +} diff --git a/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/MockClientConfig.cs b/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/MockClientConfig.cs index d19c23e..51b8c4b 100644 --- a/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/MockClientConfig.cs +++ b/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/MockClientConfig.cs @@ -10,6 +10,7 @@ public MockClientConfig() : this(new MockConfigurationProvider()) public MockClientConfig(IDefaultConfigurationProvider configurationProvider) : base(configurationProvider) { + ServiceId = MockServiceMetadata.MockServiceId; ServiceURL = "http://localhost"; } @@ -25,4 +26,4 @@ public override Endpoint DetermineServiceOperationEndpoint(ServiceOperationEndpo public override string RegionEndpointServiceName => "mock-service"; public static MockClientConfig CreateDefaultMockClientConfig() => new(new MockConfigurationProvider()); -} \ No newline at end of file +} From d181a9668a8579ac3561946ac4049f59d302e4a9 Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Fri, 23 Oct 2026 22:32:16 +0300 Subject: [PATCH 2/2] test: name the static-factory tests by the Should convention --- .../CreateClientByInterfaceTests.cs | 2 +- .../SessionTests/SessionStaticFactoryTests.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/LocalStack.Client.Integration.Tests/CreateClientByInterfaceTests.cs b/tests/LocalStack.Client.Integration.Tests/CreateClientByInterfaceTests.cs index 1c87d88..44b0409 100644 --- a/tests/LocalStack.Client.Integration.Tests/CreateClientByInterfaceTests.cs +++ b/tests/LocalStack.Client.Integration.Tests/CreateClientByInterfaceTests.cs @@ -12,7 +12,7 @@ static CreateClientByInterfaceTests() } [Fact] - public void Force_path_style_sets_s3_config_and_returns_false_for_sqs_config() + public void SetForcePathStyle_Should_Set_S3_Config_And_Return_False_For_Sqs_Config() { var reflection = new Utils.SessionReflection(); var s3Config = new AmazonS3Config(); diff --git a/tests/LocalStack.Client.Tests/SessionTests/SessionStaticFactoryTests.cs b/tests/LocalStack.Client.Tests/SessionTests/SessionStaticFactoryTests.cs index 5613474..9187986 100644 --- a/tests/LocalStack.Client.Tests/SessionTests/SessionStaticFactoryTests.cs +++ b/tests/LocalStack.Client.Tests/SessionTests/SessionStaticFactoryTests.cs @@ -4,7 +4,7 @@ namespace LocalStack.Client.Tests.SessionTests; public class SessionStaticFactoryTests { [Fact] - public void Default_reflection_uses_static_factory_without_client_naming_convention() + public void CreateClientByInterface_Should_Use_The_Static_Factory_When_The_Client_Name_Does_Not_Follow_The_Convention() { Session session = CreateSession(); @@ -18,7 +18,7 @@ public void Default_reflection_uses_static_factory_without_client_naming_convent } [Fact] - public void Default_reflection_wraps_static_factory_failure_preserving_exception_identity() + public void CreateClientByInterface_Should_Wrap_Static_Factory_Failures_In_TargetInvocationException() { Session session = CreateSession(); @@ -28,7 +28,7 @@ public void Default_reflection_wraps_static_factory_failure_preserving_exception } [Fact] - public void Static_factory_receives_the_configured_instance_with_service_url() + public void CreateClientByInterface_Should_Pass_The_Configured_ClientConfig_To_The_Static_Factory() { Session session = CreateSession(); FactoryCreatedMockClient.RecordedConfig = null;