From 9f14cf72275cb5973b1ce36bff545a6114dd6a21 Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Thu, 22 Oct 2026 14:09:29 +0300 Subject: [PATCH 1/6] feat: support Native AOT without consumer API changes --- CHANGELOG.md | 4 +- Directory.Packages.props | 4 +- LocalStack.slnx | 4 +- README.md | 7 +- docs/architecture/client-runtime.md | 8 +- .../extensions-and-composition.md | 10 +- docs/architecture/platform-and-packaging.md | 15 +- .../LocalStack.Client.Extensions.csproj | 29 +- .../LocalStackOptionsBinder.cs | 97 +++++ src/LocalStack.Client.Extensions/README.md | 7 +- .../ServiceCollectionExtensions.cs | 17 +- .../AwsRegistryGenerator.cs | 332 ++++++++++++++++++ .../GeneratedReflection.cs | 64 ++++ .../LocalStack.Client.Generators.csproj | 11 + .../LocalStack.Client.csproj | 6 +- .../Options/ConfigOptions.cs | 10 +- .../Options/LocalStackOptions.cs | 13 +- .../Options/SessionOptions.cs | 10 +- .../Properties/AssemblyInfo.cs | 3 + src/LocalStack.Client/README.md | 7 +- src/LocalStack.Client/Session.cs | 101 ++++-- .../Utils/GeneratedClientRegistry.cs | 42 +++ .../Utils/SessionReflection.cs | 127 ++++--- .../buildTransitive/LocalStack.Client.targets | 5 + tests/Directory.Build.targets | 6 + .../OptionsBindingCompatibilityTests.cs | 246 +++++++++++++ .../GeneratorTests.cs | 327 +++++++++++++++++ .../LocalStack.Client.Generators.Tests.csproj | 17 + .../SessionTests/SessionCompatibilityTests.cs | 223 ++++++++++++ 29 files changed, 1633 insertions(+), 119 deletions(-) create mode 100644 src/LocalStack.Client.Extensions/LocalStackOptionsBinder.cs create mode 100644 src/LocalStack.Client.Generators/AwsRegistryGenerator.cs create mode 100644 src/LocalStack.Client.Generators/GeneratedReflection.cs create mode 100644 src/LocalStack.Client.Generators/LocalStack.Client.Generators.csproj create mode 100644 src/LocalStack.Client/Properties/AssemblyInfo.cs create mode 100644 src/LocalStack.Client/Utils/GeneratedClientRegistry.cs create mode 100644 src/LocalStack.Client/buildTransitive/LocalStack.Client.targets create mode 100644 tests/Directory.Build.targets create mode 100644 tests/LocalStack.Client.Extensions.Tests/OptionsBindingCompatibilityTests.cs create mode 100644 tests/LocalStack.Client.Generators.Tests/GeneratorTests.cs create mode 100644 tests/LocalStack.Client.Generators.Tests/LocalStack.Client.Generators.Tests.csproj create mode 100644 tests/LocalStack.Client.Tests/SessionTests/SessionCompatibilityTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b877dfe..6925c2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,9 @@ See v1.x change log for previous versions: [CHANGELOG.md](https://github.com/loc ### General -- Replaced the AWS-mode wrapper's private SDK reflection with the public `AddAWSService` factory, preserving provider options fallback and client lifetimes. Removed its obsolete linker descriptor and reflection/AOT annotations; Core client creation and `AddLocalStack` configuration binding still do not support Native AOT. +- Replaced the AWS-mode wrapper's private SDK reflection with the public `AddAWSService` factory, preserving provider options fallback and client lifetimes. Removed its obsolete linker descriptor and reflection/AOT annotations. +- Added automatic Core source generation that roots existing reflection operations, plus lazy fixed-schema configuration binding, preserving existing C# `AddLocalStack`, AWS service registration, and Core `Session` calls. Local-package Native AOT validation covers .NET 10 on `win-x64` and `linux-x64`, including S3 requests and endpoint, region, credentials, and configured-value parity with JIT; see the [scope boundary](docs/architecture/platform-and-packaging.md#native-aot-and-trimming). +- Added `Microsoft.Extensions.Options` 8.0.2 only to modern Extensions assets for Native AOT, implying `Microsoft.Extensions.Primitives >= 8.0.0` there. Legacy dependency floors and existing intentional minimums are unchanged. - Added explicit `net10.0` target assets to both packages while retaining their existing target frameworks. - Set the development SDK baseline to 10.0.402 and configured test projects and CI workflows to cover `net10.0`. - Updated all existing non-Core centrally managed NuGet versions to their latest stable releases, including build, test, and analyzer packages (Extensions' consumer minimums are preserved below). Build-tool updates include Cake.Frosting 6.3.0 and NuGet.Versioning 7.9.0. diff --git a/Directory.Packages.props b/Directory.Packages.props index 772b316..b06559e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,8 @@ + + @@ -186,4 +188,4 @@ - \ No newline at end of file + diff --git a/LocalStack.slnx b/LocalStack.slnx index a70d15c..b9d215a 100644 --- a/LocalStack.slnx +++ b/LocalStack.slnx @@ -3,6 +3,7 @@ + @@ -27,4 +28,5 @@ - \ No newline at end of file + + diff --git a/README.md b/README.md index 0d84c47..c9520b1 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,9 @@ LocalStack.NET is a .NET client library for [LocalStack](https://github.com/loca ## ⚡ Native AOT & Trimming Status -> **Heads‑up for `dotnet publish -p:PublishAot=true` / `PublishTrimmed=true` users** - -- **The current v2 packages do not support Native AOT.** Core client creation still uses reflection over AWS SDK internals, and Extensions' configuration binding uses non-public setters. The AWS-mode wrapper's public SDK factory path does not establish AOT compatibility for the complete application. Respect `[RequiresDynamicCode]` and `[RequiresUnreferencedCode]` annotations where present; do not suppress them to obtain a successful publish. -- The reflection-free source-generator work is in [draft PR #49](https://github.com/localstack-dotnet/localstack-dotnet-client/pull/49), not in `master`. Do not suppress trimming/AOT warnings or adopt an unverified service descriptor on the assumption that this draft has shipped. See the [platform boundary](https://github.com/localstack-dotnet/localstack-dotnet-client/blob/master/docs/architecture/platform-and-packaging.md). +- **Unreleased:** Native AOT support preserves existing C# calls such as `AddLocalStack(configuration)`, `AddAwsService()`, and Core `Session` calls. Source generation flows automatically through either package; no new registration or factory API is required. It roots existing reflection operations rather than removing reflection. +- Local-package consumers have been validated with Native AOT and its mandatory trimming on .NET 10 for `win-x64` and `linux-x64`, including S3 `ListBucketsAsync` and JIT parity for endpoint, region, credentials, and configured values. This does not establish support for every service, operation, or native target, or for separately trimmed JIT deployments. Do not suppress trim/AOT warnings. +- This implementation is **not part of the published Core 2.0.0 / Extensions 2.0.1 releases**. See the [platform boundary](https://github.com/localstack-dotnet/localstack-dotnet-client/blob/master/docs/architecture/platform-and-packaging.md#native-aot-and-trimming) for scope and the [Native AOT decision](https://github.com/localstack-dotnet/localstack-dotnet-client/blob/master/docs/adr/0004-native-aot-preserves-existing-consumer-code.md) for the unchanged-consumer contract. ### Build & Test Matrix diff --git a/docs/architecture/client-runtime.md b/docs/architecture/client-runtime.md index c4f3e60..5a676f1 100644 --- a/docs/architecture/client-runtime.md +++ b/docs/architecture/client-runtime.md @@ -1,6 +1,6 @@ # Client runtime and service mapping -Date: 2026-10-17 +Date: 2026-10-22 The core package creates AWS SDK service clients configured for LocalStack. The [glossary](../../GLOSSARY.md) distinguishes an AWS service, an SDK client, and a configured LocalStack endpoint. @@ -12,8 +12,10 @@ The catalog is a mapping in **this library**, not a list of all AWS operations a ## Client creation -[`Session`](../../src/LocalStack.Client/Session.cs) accepts session options, an `IConfig` built from the endpoint catalog, and [`SessionReflection`](../../src/LocalStack.Client/Utils/SessionReflection.cs). Its interface-based path locates the AWS SDK implementation type by convention; its implementation-based path starts from the concrete type. Both inspect the SDK's private `serviceMetadata`, construct the corresponding `ClientConfig`, and create an SDK client with the configured credentials. +[`Session`](../../src/LocalStack.Client/Session.cs) retains its three-argument constructor accepting `ISessionOptions`, `IConfig`, and an injected `ISessionReflection`. Its interface-based path locates the AWS SDK implementation type by convention; its implementation-based path starts from the concrete type. Both call the injected helper to obtain service metadata and construct the corresponding `ClientConfig`, then create an SDK client with the configured credentials. The default [`SessionReflection`](../../src/LocalStack.Client/Utils/SessionReflection.cs) inspects the SDK's private `serviceMetadata`. The client configuration sets `UseHttp`, `ProxyHost`, and `ProxyPort` from the LocalStack settings. Where the configuration exposes `ForcePathStyle`, the reflection helper sets it for S3-compatible addressing. With `useServiceUrl: true`, `Session` sets the explicit `ServiceURL`; otherwise it requires a region name and sets `RegionEndpoint`. These are two configuration paths for a LocalStack-created client, **not** the application-level LocalStack/AWS mode switch. They do not by themselves prove where every later request travels. -[`SessionStandalone`](../../src/LocalStack.Client/SessionStandalone.cs) creates this session without dependency injection. The optional Extensions package owns the application-level mode switch in [Extensions and composition](extensions-and-composition.md). The Core and Extensions reflection paths remain present on current `master`; see [platform and packaging](platform-and-packaging.md) for the Native AOT boundary. +On modern .NET assets, the unreleased implementation automatically generates rooted reflection operations for client and configuration types discovered in C# source and references, including closed types found in declaration signatures. An internal registry uses actual runtime type identity. Initialization stores delegates only: metadata access, configuration creation, property mutation, and client construction remain lazy at their original call sites. Generated hits are used on JIT too; dynamic-code-supported misses and legacy assets retain reflection fallbacks. This preserves injected helper calls, their ordering, metadata/configuration identity, and reflection constructor/property behavior rather than bypassing them with a separate factory path. It does not enable arbitrary unseen plugins to be loaded under Native AOT. + +[`SessionStandalone`](../../src/LocalStack.Client/SessionStandalone.cs) creates this session without dependency injection. The optional Extensions package owns the application-level mode switch in [Extensions and composition](extensions-and-composition.md). See [platform and packaging](platform-and-packaging.md#native-aot-and-trimming) for the unreleased Native AOT validation boundary and automatic compiler-asset packaging. diff --git a/docs/architecture/extensions-and-composition.md b/docs/architecture/extensions-and-composition.md index 7fe21bc..1218d55 100644 --- a/docs/architecture/extensions-and-composition.md +++ b/docs/architecture/extensions-and-composition.md @@ -1,13 +1,15 @@ # Extensions and application composition -Date: 2026-10-20 +Date: 2026-10-22 `LocalStack.Client.Extensions` integrates the core package with .NET configuration and dependency injection. The [client runtime](client-runtime.md) owns endpoint mapping and LocalStack client creation; this document owns application-level composition. -[`AddLocalStack(IConfiguration)`](../../src/LocalStack.Client.Extensions/ServiceCollectionExtensions.cs) binds the `LocalStack` section to `LocalStackOptions`, `SessionOptions`, and `ConfigOptions`, including their non-public setters. It registers `IConfig` as transient and `ISessionReflection`, `ISession`, and `IAwsClientFactoryWrapper` as singletons. Registration prepares both client-creation paths; it does not itself enable LocalStack mode. +[`AddLocalStack(IConfiguration)`](../../src/LocalStack.Client.Extensions/ServiceCollectionExtensions.cs) binds the `LocalStack` section to `LocalStackOptions`, `SessionOptions`, and `ConfigOptions`. The unreleased implementation uses [fixed-schema typed mutation](../../src/LocalStack.Client.Extensions/LocalStackOptionsBinder.cs), not general reflective configuration binding. Private setters and defaults are retained. Binding stays lazy inside the existing three `Configure` registrations, preserving conversion behavior and the framework's configure/post-configure, validation, and caching pipeline. The aggregate's nested session/configuration objects and the separately registered options remain independent instances. + +It registers `IConfig` as transient and `ISessionReflection`, `ISession`, and `IAwsClientFactoryWrapper` as singletons. Registration prepares both client-creation paths; it does not itself enable LocalStack mode. Existing C# registrations receive Core's source generation automatically, including when only the Extensions package is referenced; no consumer registration change is needed. The [platform boundary](platform-and-packaging.md#native-aot-and-trimming) owns the unreleased Native AOT scope. `AddAwsService` registers an AWS service interface (singleton by default). When the configured `UseLocalStack` value is `true`, resolution calls `Session.CreateClientByInterface` and uses the LocalStack catalog. When it is `false`—the options default—resolution calls [`AwsClientFactoryWrapper`](../../src/LocalStack.Client.Extensions/AwsClientFactoryWrapper.cs) to create an ordinary AWS SDK client. `AddAWSServiceLocalStack` is an alias for the registration method, **not** an override of the mode switch. `TryAddAwsService` only adds a registration when one is absent. -The AWS-mode wrapper obtains the client factory from the SDK's public `AddAWSService` registration and invokes it with the original `IServiceProvider`. This preserves SDK options fallback and logger access without reflecting over private SDK members or constructing a second provider. Explicit options still override provider defaults, the outer registration owns the client lifetime, and factory exceptions retain the `TargetInvocationException` boundary. The public wrapper and interface remain available. The [decision record](../adr/0002-aws-setup-constructor-compatibility.md) explains the provider-aware public factory choice; the [platform boundary](platform-and-packaging.md#native-aot-and-trimming) describes the remaining AOT limits. +The AWS-mode wrapper obtains the client factory from the SDK's public `AddAWSService` registration and invokes it with the original `IServiceProvider`. This preserves SDK options fallback and logger access without reflecting over private SDK members or constructing a second provider. Explicit options still override provider defaults, the outer registration owns the client lifetime, and factory exceptions retain the `TargetInvocationException` boundary. The public wrapper and interface remain available. The [decision record](../adr/0002-aws-setup-constructor-compatibility.md) explains the provider-aware public factory choice. -**Current overload caveat:** the shorter `TryAddAwsService(lifetime, useServiceUrl)` overload does not forward its `useServiceUrl` argument (`ServiceCollectionExtensions.cs:126-129`). The overload that accepts `AWSOptions?` does forward it. Do not describe URL selection as reliable across both overloads until the implementation changes. The `useServiceUrl` switch itself is distinct from `UseLocalStack`: it selects a client configuration path only after the LocalStack session is chosen. +**Current overload caveat:** the shorter `TryAddAwsService(lifetime, useServiceUrl)` overload does not forward its `useServiceUrl` argument. The overload that accepts `AWSOptions?` does forward it. Do not describe URL selection as reliable across both overloads until the implementation changes. The `useServiceUrl` switch itself is distinct from `UseLocalStack`: it selects a client configuration path only after the LocalStack session is chosen. diff --git a/docs/architecture/platform-and-packaging.md b/docs/architecture/platform-and-packaging.md index 7134c7c..8a00062 100644 --- a/docs/architecture/platform-and-packaging.md +++ b/docs/architecture/platform-and-packaging.md @@ -1,6 +1,6 @@ # Platform and packaging boundary -Date: 2026-10-20 +Date: 2026-10-22 The source of package versions, target frameworks, and dependencies is the project configuration, not a prose copy of their values: @@ -13,7 +13,7 @@ The source of package versions, target frameworks, and dependencies is the proje | Development SDK selection | [`global.json`](../../global.json) | | Publication triggers and credentials mechanism | [GitHub Actions workflows](../../.github/workflows/) | -For Extensions' four target frameworks (`netstandard2.0`, `net8.0`, `net9.0`, and `net10.0`), the Microsoft.Extensions `VersionOverride` values in [LocalStack.Client.Extensions.csproj](../../src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj) define these existing consumer minimums: +For Extensions' four target frameworks (`netstandard2.0`, `net8.0`, `net9.0`, and `net10.0`), the conditional references and `VersionOverride` values in [LocalStack.Client.Extensions.csproj](../../src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj), together with the central `Microsoft.Extensions.Options` version, define these consumer minimums: | Package | Declared minimum | Target frameworks | | --- | --- | --- | @@ -23,16 +23,23 @@ For Extensions' four target frameworks (`netstandard2.0`, `net8.0`, `net9.0`, an | `Microsoft.Extensions.Options.ConfigurationExtensions` | 3.1.32 | All four | | `Microsoft.Extensions.DependencyInjection.Abstractions` | 3.1.32 | `netstandard2.0` | | `Microsoft.Extensions.DependencyInjection.Abstractions` | 8.0.0 | `net8.0`, `net9.0`, `net10.0` | +| `Microsoft.Extensions.Options` | 8.0.2 | `net8.0`, `net9.0`, `net10.0` | These are this package's consumer minimums, not a copy of the selected [`AWSSDK.Extensions.NETCore.Setup` 4.0.102.4 package ranges](https://www.nuget.org/packages/AWSSDK.Extensions.NETCore.Setup/4.0.102.4). Setup 4.0.102.4 permits older 2.x configuration, logging, and legacy dependency-injection packages, and a `net8.0` dependency-injection package at 8.0.0; those permissive ranges do not lower or define Extensions' own contract. Central versions in [Directory.Packages.props](../../Directory.Packages.props) govern test and sandbox consumers separately. A consumer's resolved graph can select a higher version when another dependency requires it. +The modern-only Options 8.0.2 reference is an unreleased Native AOT fix, not a blanket dependency refresh. It also raises the transitive `Microsoft.Extensions.Primitives` minimum to 8.0.0 on those assets. The `netstandard2.0` dependency floor is unchanged. The centrally managed 10.0.13 versions of other Microsoft.Extensions packages used by tests and sandboxes are not consumer minimums; successful execution with a higher resolved graph does not qualify every declared minimum-version combination. + At this revision Core explicitly targets `netstandard2.0`, `net472`, `net8.0`, `net9.0`, and `net10.0`; Extensions targets `netstandard2.0`, `net8.0`, `net9.0`, and `net10.0`. The AWS SDK v4 migration changed the explicit .NET Framework target from 4.6.2 to 4.7.2. The two packages can have different versions: the [changelog](../../CHANGELOG.md) records the Extensions-only 2.0.1 release. For an installation instruction, consult the actual package feed as well as the declared project version. ## Native AOT and trimming -Core client creation still reflects over AWS SDK internals, and Extensions' configuration binding still uses non-public setters. Their annotations warn consumers about dynamic code and trimming. The AWS-mode wrapper uses the SDK's public registration factory and no longer needs its own private reflection or linker descriptor; this bounded improvement does not make `AddLocalStack` or the Core session Native AOT compatible. Native AOT support is **not** a capability of the current published v2 line; the source-generator/`UnsafeAccessor` work in [draft PR #49](https://github.com/localstack-dotnet/localstack-dotnet-client/pull/49) has not shipped. Do not treat a proposed release number as a delivery date. +The **unreleased implementation** preserves existing C# entry points through automatic source generation and fixed-schema options binding. Core packages a compiler-only generator and a [`buildTransitive` target](../../src/LocalStack.Client/buildTransitive/LocalStack.Client.targets) that enables it for .NET 8-compatible targets. It flows to Core-only and Extensions-only package consumers without manual generator enrollment and introduces no runtime compiler dependency. Generated operations root the existing metadata, constructor, and property reflection; this is not a reflection-free or parallel consumer API. The [client runtime](client-runtime.md#client-creation) and [composition](extensions-and-composition.md) documents own those behaviors. + +Local-package validation covers .NET 10 Native AOT with its mandatory trimming on `win-x64` and `linux-x64`: unchanged `AddLocalStack(configuration)` / `AddAwsService()` resolution and `ListBucketsAsync`, the eight existing S3 Add/TryAdd/alias registration overloads, and Core `Session` generic/`Type` paths and `SessionStandalone`. Core-only, Extensions-only, and combined consumers publish without trim/AOT warnings or authored suppressions and run against LocalStack. Matching JIT runs verify endpoint, region, credentials, and configured-value parity, while retaining the documented shorter TryAdd overload caveat. + +This is not a claim for every cataloged service or operation, other native RIDs or frameworks, non-C# consumer compilers, or arbitrary runtime-loaded types absent from the compiled graph. Modern assets contain the implementation, but no separate trimmed-JIT deployment promise or `IsAotCompatible` declaration is made. Do not suppress trim/AOT warnings to obtain a successful publication. -The [Native AOT decision](../adr/0004-native-aot-preserves-existing-consumer-code.md) defines what support means. Existing consumer calls must publish and run under Native AOT unchanged, so an additive AOT-only API is not support on its own. +This capability is **not part of the published Core 2.0.0 / Extensions 2.0.1 releases**; no release version or delivery date is assigned here. The [Native AOT decision](../adr/0004-native-aot-preserves-existing-consumer-code.md) defines the unchanged-consumer contract. Earlier draft generator work is not evidence that a released package meets it. ## Consumer README and release channels diff --git a/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj b/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj index 7c89c70..74a3876 100644 --- a/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj +++ b/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj @@ -25,36 +25,37 @@ - + - - - - + + + + - + - + + - + - + - + - - + + @@ -64,11 +65,11 @@ Always - + - + diff --git a/src/LocalStack.Client.Extensions/LocalStackOptionsBinder.cs b/src/LocalStack.Client.Extensions/LocalStackOptionsBinder.cs new file mode 100644 index 0000000..b026c0c --- /dev/null +++ b/src/LocalStack.Client.Extensions/LocalStackOptionsBinder.cs @@ -0,0 +1,97 @@ +using System.ComponentModel; +using System.Linq; + +namespace LocalStack.Client.Extensions; + +// This is the fixed LocalStack schema, not a replacement for the general configuration binder. +// Keep evaluation inside Configure callbacks so the framework owns order, validation and caching. +internal static class LocalStackOptionsBinder +{ + public static void Bind(IConfiguration section, LocalStackOptions options) + { + options.ApplyConfiguration(ReadBoolean(section.GetSection(nameof(LocalStackOptions.UseLocalStack)))); + IConfigurationSection sessionSection = section.GetSection(nameof(LocalStackOptions.Session)); + SessionOptions? session = options.Session; + if (session == null && sessionSection.GetChildren().Any()) + { + session = new SessionOptions(); + } + + if (session != null) + { + Bind(sessionSection, session); + options.ApplyConfiguration(session: session); + } + + IConfigurationSection configSection = section.GetSection(nameof(LocalStackOptions.Config)); + ConfigOptions? config = options.Config; + if (config == null && configSection.GetChildren().Any()) + { + config = new ConfigOptions(); + } + + if (config != null) + { + Bind(configSection, config); + options.ApplyConfiguration(config: config); + } + } + + public static void Bind(IConfiguration section, SessionOptions options) + { + options.ApplyConfiguration(awsAccessKeyId: section.GetSection(nameof(SessionOptions.AwsAccessKeyId)).Value); + options.ApplyConfiguration(awsAccessKey: section.GetSection(nameof(SessionOptions.AwsAccessKey)).Value); + options.ApplyConfiguration(awsSessionToken: section.GetSection(nameof(SessionOptions.AwsSessionToken)).Value); + options.ApplyConfiguration(regionName: section.GetSection(nameof(SessionOptions.RegionName)).Value); + } + + public static void Bind(IConfiguration section, ConfigOptions options) + { + options.ApplyConfiguration(localStackHost: section.GetSection(nameof(ConfigOptions.LocalStackHost)).Value); + options.ApplyConfiguration(useSsl: ReadBoolean(section.GetSection(nameof(ConfigOptions.UseSsl)))); + options.ApplyConfiguration(useLegacyPorts: ReadBoolean(section.GetSection(nameof(ConfigOptions.UseLegacyPorts)))); + options.ApplyConfiguration(edgePort: ReadInt32(section.GetSection(nameof(ConfigOptions.EdgePort)))); + } + + private static bool? ReadBoolean(IConfigurationSection section) + { + string? value = section.Value; + if (value == null) + { + return null; + } + + try + { + return (bool)new BooleanConverter().ConvertFromInvariantString(value)!; + } + catch (Exception exception) + { + throw ConversionError(section.Path, typeof(bool), exception); + } + } + + private static int? ReadInt32(IConfigurationSection section) + { + string? value = section.Value; + if (value == null) + { + return null; + } + + try + { + // Int32Converter also accepts 0x, &h and # hexadecimal forms, unlike int.Parse. + return (int)new Int32Converter().ConvertFromInvariantString(value)!; + } + catch (Exception exception) + { + // The desktop converter throws System.Exception; restrict the catch to conversion, + // not configuration access or option mutation, just as ConfigurationBinder does. + throw ConversionError(section.Path, typeof(int), exception); + } + } + + private static InvalidOperationException ConversionError(string path, Type type, Exception exception) => + new($"Failed to convert configuration value at '{path}' to type '{type}'.", exception); +} diff --git a/src/LocalStack.Client.Extensions/README.md b/src/LocalStack.Client.Extensions/README.md index 0d84c47..c9520b1 100644 --- a/src/LocalStack.Client.Extensions/README.md +++ b/src/LocalStack.Client.Extensions/README.md @@ -32,10 +32,9 @@ LocalStack.NET is a .NET client library for [LocalStack](https://github.com/loca ## ⚡ Native AOT & Trimming Status -> **Heads‑up for `dotnet publish -p:PublishAot=true` / `PublishTrimmed=true` users** - -- **The current v2 packages do not support Native AOT.** Core client creation still uses reflection over AWS SDK internals, and Extensions' configuration binding uses non-public setters. The AWS-mode wrapper's public SDK factory path does not establish AOT compatibility for the complete application. Respect `[RequiresDynamicCode]` and `[RequiresUnreferencedCode]` annotations where present; do not suppress them to obtain a successful publish. -- The reflection-free source-generator work is in [draft PR #49](https://github.com/localstack-dotnet/localstack-dotnet-client/pull/49), not in `master`. Do not suppress trimming/AOT warnings or adopt an unverified service descriptor on the assumption that this draft has shipped. See the [platform boundary](https://github.com/localstack-dotnet/localstack-dotnet-client/blob/master/docs/architecture/platform-and-packaging.md). +- **Unreleased:** Native AOT support preserves existing C# calls such as `AddLocalStack(configuration)`, `AddAwsService()`, and Core `Session` calls. Source generation flows automatically through either package; no new registration or factory API is required. It roots existing reflection operations rather than removing reflection. +- Local-package consumers have been validated with Native AOT and its mandatory trimming on .NET 10 for `win-x64` and `linux-x64`, including S3 `ListBucketsAsync` and JIT parity for endpoint, region, credentials, and configured values. This does not establish support for every service, operation, or native target, or for separately trimmed JIT deployments. Do not suppress trim/AOT warnings. +- This implementation is **not part of the published Core 2.0.0 / Extensions 2.0.1 releases**. See the [platform boundary](https://github.com/localstack-dotnet/localstack-dotnet-client/blob/master/docs/architecture/platform-and-packaging.md#native-aot-and-trimming) for scope and the [Native AOT decision](https://github.com/localstack-dotnet/localstack-dotnet-client/blob/master/docs/adr/0004-native-aot-preserves-existing-consumer-code.md) for the unchanged-consumer contract. ### Build & Test Matrix diff --git a/src/LocalStack.Client.Extensions/ServiceCollectionExtensions.cs b/src/LocalStack.Client.Extensions/ServiceCollectionExtensions.cs index 6afe80b..fbe4ec3 100644 --- a/src/LocalStack.Client.Extensions/ServiceCollectionExtensions.cs +++ b/src/LocalStack.Client.Extensions/ServiceCollectionExtensions.cs @@ -4,20 +4,15 @@ public static class ServiceCollectionExtensions { private const string LocalStackSectionName = "LocalStack"; -#if NET8_0_OR_GREATER - [RequiresUnreferencedCode("ConfigurationBinder.Bind() sets options via reflection; keep non‑public setters or suppress in AOT build.")] -#endif public static IServiceCollection AddLocalStack(this IServiceCollection collection, IConfiguration configuration) { - collection.Configure(options => configuration.GetSection(LocalStackSectionName).Bind(options, c => c.BindNonPublicProperties = true)); + collection.Configure(options => LocalStackOptionsBinder.Bind(configuration.GetSection(LocalStackSectionName), options)); - collection.Configure(options => configuration.GetSection(LocalStackSectionName) - .GetSection(nameof(LocalStackOptions.Session)) - .Bind(options, c => c.BindNonPublicProperties = true)); + collection.Configure(options => LocalStackOptionsBinder.Bind(configuration.GetSection(LocalStackSectionName) + .GetSection(nameof(LocalStackOptions.Session)), options)); - collection.Configure(options => configuration.GetSection(LocalStackSectionName) - .GetSection(nameof(LocalStackOptions.Config)) - .Bind(options, c => c.BindNonPublicProperties = true)); + collection.Configure(options => LocalStackOptionsBinder.Bind(configuration.GetSection(LocalStackSectionName) + .GetSection(nameof(LocalStackOptions.Config)), options)); return collection.AddLocalStackServices(); } @@ -231,4 +226,4 @@ private static ServiceDescriptor GetServiceFactoryDescriptor(AWSOption return descriptor; } -} \ No newline at end of file +} diff --git a/src/LocalStack.Client.Generators/AwsRegistryGenerator.cs b/src/LocalStack.Client.Generators/AwsRegistryGenerator.cs new file mode 100644 index 0000000..1f374a3 --- /dev/null +++ b/src/LocalStack.Client.Generators/AwsRegistryGenerator.cs @@ -0,0 +1,332 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace LocalStack.Client.Generators; + +[Generator(LanguageNames.CSharp)] +public sealed class AwsRegistryGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Symbols never escape this transform. The emitted value is equatable, so edits which + // do not change the discovered graph do not invalidate source output. + IncrementalValueProvider source = context.CompilationProvider + .Select(static (compilation, cancellationToken) => Generate(compilation, cancellationToken)) + .WithComparer(StringComparer.Ordinal); + context.RegisterSourceOutput(source, static (output, text) => + { + if (text.Length != 0) + { + output.AddSource("LocalStackGeneratedRegistry.g.cs", SourceText.From(text, Encoding.UTF8)); + } + }); + } + + private static string Generate(Compilation compilation, CancellationToken cancellationToken) + { + INamedTypeSymbol? clientBase = compilation.GetTypeByMetadataName("Amazon.Runtime.AmazonServiceClient"); + INamedTypeSymbol? configBase = compilation.GetTypeByMetadataName("Amazon.Runtime.ClientConfig"); + if (clientBase == null || configBase == null || compilation.GetTypeByMetadataName("LocalStack.Client.Session") == null || + compilation.GetTypeByMetadataName("System.Runtime.CompilerServices.UnsafeAccessorAttribute") == null) + { + return ""; + } + + var clients = new HashSet(SymbolEqualityComparer.Default); + var configs = new HashSet(SymbolEqualityComparer.Default); + void Discover(INamedTypeSymbol type) + { + cancellationToken.ThrowIfCancellationRequested(); + if (ContainsTypeParameter(type)) + { + return; + } + + if (DerivesFrom(type, configBase)) + { + configs.Add(type); + } + else if (DerivesFrom(type, clientBase) || type.GetMembers("serviceMetadata").OfType().Any() || HasConfigurationConstructor(type, clientBase, configBase)) + { + clients.Add(type); + } + } + + var visitedTypes = new HashSet(SymbolEqualityComparer.Default); + Action discoverSignature = type => DiscoverSignature(type, visitedTypes, Discover, cancellationToken); + DiscoverReferences(compilation, clientBase, discoverSignature, cancellationToken); + + // Definitions alone cannot describe closed custom generic clients. Obtain actual + // constructed symbols from consumer type syntax, including typeof and Type variables. + foreach (SyntaxTree tree in compilation.SyntaxTrees) + { + SemanticModel semanticModel = compilation.GetSemanticModel(tree); + foreach (GenericNameSyntax syntax in tree.GetRoot(cancellationToken).DescendantNodes().OfType()) + { + if (semanticModel.GetTypeInfo(syntax, cancellationToken).Type is INamedTypeSymbol type) + { + discoverSignature(type); + } + } + } + + foreach (IParameterSymbol parameter in clients.SelectMany(client => client.InstanceConstructors).SelectMany(constructor => constructor.Parameters)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (parameter.Type is INamedTypeSymbol type && !ContainsTypeParameter(type) && DerivesFrom(type, configBase)) + { + configs.Add(type); + } + } + + return Emit(compilation, clients, configs); + } + + private static bool HasConfigurationConstructor(INamedTypeSymbol type, INamedTypeSymbol clientBase, INamedTypeSymbol configBase) + { + INamedTypeSymbol? credentials = clientBase.ContainingAssembly.GetTypeByMetadataName("Amazon.Runtime.AWSCredentials"); + return type.InstanceConstructors.Any(constructor => constructor.DeclaredAccessibility == Accessibility.Public && constructor.Parameters.Length == 2 && + string.Equals(constructor.Parameters[0].Name, "credentials", StringComparison.Ordinal) && + SymbolEqualityComparer.Default.Equals(constructor.Parameters[0].Type, credentials) && + string.Equals(constructor.Parameters[1].Name, "clientConfig", StringComparison.Ordinal) && + constructor.Parameters[1].Type is INamedTypeSymbol config && !SymbolEqualityComparer.Default.Equals(config, configBase) && DerivesFrom(config, configBase)); + } + + private static void DiscoverSignature(ITypeSymbol type, HashSet visited, Action discover, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!visited.Add(type)) + { + return; + } + + if (type is IArrayTypeSymbol array) + { + DiscoverSignature(array.ElementType, visited, discover, cancellationToken); + } + else if (type is INamedTypeSymbol named) + { + discover(named); + foreach (ITypeSymbol argument in named.TypeArguments) + { + DiscoverSignature(argument, visited, discover, cancellationToken); + } + + if (named.BaseType != null) + { + DiscoverSignature(named.BaseType, visited, discover, cancellationToken); + } + } + } + + private static void DiscoverReferences(Compilation compilation, INamedTypeSymbol clientBase, Action discover, CancellationToken cancellationToken) + { + // Other libraries may contain custom clients/configs. Do not restrict discovery to + // AWSSDK.* names or to AddAwsService syntax (generic forwarding has no such syntax). + foreach (IAssemblySymbol assembly in compilation.SourceModule.ReferencedAssemblySymbols.Prepend(compilation.Assembly).Where(assembly => + SymbolEqualityComparer.Default.Equals(assembly, compilation.Assembly) || + ReferencesAssembly(assembly, clientBase.ContainingAssembly, new HashSet(SymbolEqualityComparer.Default), cancellationToken))) + { + VisitNamespace(assembly.GlobalNamespace, discover, cancellationToken); + } + } + + private static bool ReferencesAssembly(IAssemblySymbol assembly, IAssemblySymbol target, HashSet visited, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (SymbolEqualityComparer.Default.Equals(assembly, target)) + { + return true; + } + + return visited.Add(assembly) && assembly.Modules.Any(module => module.ReferencedAssemblySymbols.Any(reference => ReferencesAssembly(reference, target, visited, cancellationToken))); + } + + private static string Emit(Compilation compilation, HashSet clients, HashSet configs) + { + var source = new StringBuilder(GeneratedReflection.Header); + INamedTypeSymbol[] orderedClients = clients.OrderBy(AssemblyQualifiedName, StringComparer.Ordinal).ToArray(); + INamedTypeSymbol[] orderedConfigs = configs.OrderBy(AssemblyQualifiedName, StringComparer.Ordinal).ToArray(); + source.AppendLine("[global::System.Runtime.CompilerServices.ModuleInitializerAttribute] internal static void Initialize() {"); + for (int index = 0; index < orderedClients.Length; index++) + { + string suffix = index.ToString(System.Globalization.CultureInfo.InvariantCulture); + source.Append("RegisterClient(null!, ").Append(TypeExpression(compilation, orderedClients[index])) + .Append(", Metadata").Append(suffix).Append(", Config").Append(suffix) + .Append(", Constructor").Append(suffix).Append(", Create").Append(suffix).AppendLine(");"); + } + + for (int index = 0; index < orderedConfigs.Length; index++) + { + string suffix = index.ToString(System.Globalization.CultureInfo.InvariantCulture); + source.Append("RegisterConfig(null!, ").Append(TypeExpression(compilation, orderedConfigs[index])) + .Append(", Style").Append(suffix).Append(", Region").Append(suffix).AppendLine(");"); + } + + source.AppendLine("}"); + for (int index = 0; index < orderedClients.Length; index++) + { + string suffix = index.ToString(System.Globalization.CultureInfo.InvariantCulture); + string type = TypeExpression(compilation, orderedClients[index]); + source.Append("private static IServiceMetadata Metadata").Append(suffix).Append("() => Metadata(").Append(type).AppendLine(");"); + source.Append("private static ClientConfig Config").Append(suffix).Append("() => CreateConfig(FindConfigType(").Append(type).AppendLine("));"); + source.Append("private static ConstructorInfo? Constructor").Append(suffix).Append("(Type config) => Constructor(").Append(type).AppendLine(", config);"); + source.Append("private static AmazonServiceClient Create").Append(suffix).Append("(AWSCredentials credentials, ClientConfig config) => Create(") + .Append(type).AppendLine(", credentials, config);"); + } + + for (int index = 0; index < orderedConfigs.Length; index++) + { + string suffix = index.ToString(System.Globalization.CultureInfo.InvariantCulture); + string type = TypeExpression(compilation, orderedConfigs[index]); + source.Append("private static bool Style").Append(suffix).Append("(ClientConfig config, bool value) => Style(").Append(type).AppendLine(", config, value);"); + source.Append("private static void Region").Append(suffix).Append("(object config, string name) => Region(").Append(type).AppendLine(", config, name);"); + } + + source.AppendLine("private static ClientConfig CreateConfig(Type type) {"); + foreach (INamedTypeSymbol config in orderedConfigs) + { + string type = TypeExpression(compilation, config); + source.Append("if (type == ").Append(type).Append(") return (ClientConfig)Activator.CreateInstance(").Append(type).AppendLine(")!;"); + } + + source.AppendLine("throw new InvalidOperationException($\"Invalid client configuration type {type}\"); }"); + return source.Append(GeneratedReflection.Methods).AppendLine("}").ToString(); + } + + private static void VisitNamespace(INamespaceSymbol space, Action visit, CancellationToken cancellationToken) + { + foreach (INamespaceOrTypeSymbol member in space.GetMembers()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (member is INamespaceSymbol child) + { + VisitNamespace(child, visit, cancellationToken); + } + else if (member is INamedTypeSymbol type) + { + VisitType(type, visit, cancellationToken); + } + } + } + + private static void VisitType(INamedTypeSymbol type, Action visit, CancellationToken cancellationToken) + { + visit(type); + // Inspect declarations, not referenced IL or all members of arbitrary constructed + // containers. The type walk unwraps signatures and constructed bases with cycle checks. + foreach (ISymbol member in type.GetMembers()) + { + cancellationToken.ThrowIfCancellationRequested(); + switch (member) + { + case IMethodSymbol method: + visit(method.ReturnType); + foreach (IParameterSymbol parameter in method.Parameters) + { + visit(parameter.Type); + } + + break; + case IPropertySymbol property: + visit(property.Type); + break; + case IFieldSymbol field: + visit(field.Type); + break; + } + } + + foreach (INamedTypeSymbol nested in type.GetTypeMembers()) + { + VisitType(nested, visit, cancellationToken); + } + } + + private static bool ContainsTypeParameter(ITypeSymbol type) => type switch + { + ITypeParameterSymbol => true, + IArrayTypeSymbol array => ContainsTypeParameter(array.ElementType), + INamedTypeSymbol named => named.TypeArguments.Any(ContainsTypeParameter) || + (named.ContainingType != null && ContainsTypeParameter(named.ContainingType)), + _ => false, + }; + + private static bool DerivesFrom(INamedTypeSymbol type, INamedTypeSymbol baseType) + { + for (INamedTypeSymbol? current = type; current != null; current = current.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(current, baseType)) + { + return true; + } + } + + return false; + } + + private static string TypeExpression(Compilation compilation, INamedTypeSymbol type) + { + if (compilation.IsSymbolAccessibleWithin(type, compilation.Assembly) && + SymbolEqualityComparer.Default.Equals(compilation.GetTypeByMetadataName(MetadataName(type)), type.OriginalDefinition)) + { + return "typeof(" + type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ")"; + } + + // Constant assembly-qualified Type.GetType is understood by the linker and can root + // inaccessible nested types without exposing a runtime registration API. + return "Type.GetType(" + SymbolDisplay.FormatLiteral(AssemblyQualifiedName(type), quote: true) + ", throwOnError: true)!"; + } + + private static string MetadataName(INamedTypeSymbol type) + { + string prefix = type.ContainingNamespace.IsGlobalNamespace ? "" : type.ContainingNamespace.ToDisplayString() + "."; + return type.ContainingType != null ? MetadataName(type.ContainingType) + "+" + type.MetadataName : prefix + type.MetadataName; + } + + private static string AssemblyQualifiedName(ITypeSymbol type) => ReflectionName(type) + ", " + TypeAssembly(type); + + private static string TypeAssembly(ITypeSymbol type) => type is IArrayTypeSymbol array + ? TypeAssembly(array.ElementType) + : type.ContainingAssembly?.Identity.GetDisplayName() ?? "System.Runtime"; + + private static string ReflectionName(ITypeSymbol type) + { + if (type is IArrayTypeSymbol array) + { + return ReflectionName(array.ElementType) + "[" + new string(',', array.Rank - 1) + "]"; + } + + if (type is not INamedTypeSymbol named) + { + return "System.Object"; // dynamic is System.Object in runtime type identity. + } + + var arguments = new List(); + CollectArguments(named, arguments); + string name = MetadataName(named); + if (arguments.Count != 0) + { + name += "[" + string.Join(",", arguments.Select(argument => "[" + AssemblyQualifiedName(argument) + "]")) + "]"; + } + + return name; + } + + private static void CollectArguments(INamedTypeSymbol type, List arguments) + { + if (type.ContainingType != null) + { + CollectArguments(type.ContainingType, arguments); + } + + arguments.AddRange(type.TypeArguments); + } +} diff --git a/src/LocalStack.Client.Generators/GeneratedReflection.cs b/src/LocalStack.Client.Generators/GeneratedReflection.cs new file mode 100644 index 0000000..2e936bb --- /dev/null +++ b/src/LocalStack.Client.Generators/GeneratedReflection.cs @@ -0,0 +1,64 @@ +namespace LocalStack.Client.Generators; + +internal static class GeneratedReflection +{ + public const string Header = """ + // + #nullable enable + using global::System; + using global::System.Linq; + using global::System.Reflection; + using Type = global::System.Type; + using InvalidOperationException = global::System.InvalidOperationException; + using Activator = global::System.Activator; + using ConstructorInfo = global::System.Reflection.ConstructorInfo; + using FieldInfo = global::System.Reflection.FieldInfo; + using PropertyInfo = global::System.Reflection.PropertyInfo; + using ParameterInfo = global::System.Reflection.ParameterInfo; + using BindingFlags = global::System.Reflection.BindingFlags; + using RegionEndpoint = global::Amazon.RegionEndpoint; + using AWSCredentials = global::Amazon.Runtime.AWSCredentials; + using ClientConfig = global::Amazon.Runtime.ClientConfig; + using AmazonServiceClient = global::Amazon.Runtime.AmazonServiceClient; + using IServiceMetadata = global::Amazon.Runtime.Internal.IServiceMetadata; + namespace LocalStack.Client.Generated; + internal static class LocalStackGeneratedRegistry { + [global::System.Runtime.CompilerServices.UnsafeAccessorAttribute(global::System.Runtime.CompilerServices.UnsafeAccessorKind.StaticMethod, Name = "RegisterGeneratedClient")] + private static extern void RegisterClient(global::LocalStack.Client.Session unused, Type type, + global::System.Func metadata, global::System.Func config, global::System.Func constructor, + global::System.Func create); + [global::System.Runtime.CompilerServices.UnsafeAccessorAttribute(global::System.Runtime.CompilerServices.UnsafeAccessorKind.StaticMethod, Name = "RegisterGeneratedConfig")] + private static extern void RegisterConfig(global::LocalStack.Client.Session unused, Type type, + global::System.Func style, global::System.Action region); + + """; + + public const string Methods = """ + private static IServiceMetadata Metadata([global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembersAttribute(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.NonPublicFields)] Type type) { + FieldInfo field = type.GetField("serviceMetadata", BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Invalid service type {type}"); + return (IServiceMetadata)field.GetValue(null)!; + } + private static Type FindConfigType([global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembersAttribute(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] Type type) { + ConstructorInfo constructor = type.GetConstructors(BindingFlags.Instance | BindingFlags.Public).Single(info => { + ParameterInfo[] parameters = info.GetParameters(); + return parameters.Length == 2 && parameters[0].Name == "credentials" && parameters[0].ParameterType == typeof(AWSCredentials) + && parameters[1].Name == "clientConfig" && parameters[1].ParameterType.IsSubclassOf(typeof(ClientConfig)); + }); + return constructor.GetParameters()[1].ParameterType; + } + private static ConstructorInfo? Constructor([global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembersAttribute(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] Type type, Type config) => + type.GetConstructor(new[] { typeof(AWSCredentials), config }); + private static AmazonServiceClient Create([global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembersAttribute(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] Type type, AWSCredentials credentials, ClientConfig config) => + (AmazonServiceClient)Activator.CreateInstance(type, credentials, config)!; + private static bool Style([global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembersAttribute(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties)] Type type, ClientConfig config, bool value) { + PropertyInfo? property = type.GetProperty("ForcePathStyle", BindingFlags.Public | BindingFlags.Instance); + if (property == null) return false; + property.SetValue(config, value); + return true; + } + private static void Region([global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembersAttribute(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties)] Type type, object config, string name) => + type.GetProperty("RegionEndpoint", BindingFlags.Public | BindingFlags.Instance)?.SetValue(config, RegionEndpoint.GetBySystemName(name)); + + """; +} diff --git a/src/LocalStack.Client.Generators/LocalStack.Client.Generators.csproj b/src/LocalStack.Client.Generators/LocalStack.Client.Generators.csproj new file mode 100644 index 0000000..a93a653 --- /dev/null +++ b/src/LocalStack.Client.Generators/LocalStack.Client.Generators.csproj @@ -0,0 +1,11 @@ + + + netstandard2.0 + false + true + false + + + + + diff --git a/src/LocalStack.Client/LocalStack.Client.csproj b/src/LocalStack.Client/LocalStack.Client.csproj index 6ea6a1d..a26f5d6 100644 --- a/src/LocalStack.Client/LocalStack.Client.csproj +++ b/src/LocalStack.Client/LocalStack.Client.csproj @@ -28,6 +28,10 @@ + + + @@ -55,4 +59,4 @@ NET472 - \ No newline at end of file + diff --git a/src/LocalStack.Client/Options/ConfigOptions.cs b/src/LocalStack.Client/Options/ConfigOptions.cs index f4c5957..1b08a39 100644 --- a/src/LocalStack.Client/Options/ConfigOptions.cs +++ b/src/LocalStack.Client/Options/ConfigOptions.cs @@ -27,4 +27,12 @@ public ConfigOptions(string localStackHost = Constants.LocalStackHost, bool useS public bool UseLegacyPorts { get; private set;} = Constants.UseLegacyPorts; public int EdgePort { get; private set;} = Constants.EdgePort; -} \ No newline at end of file + + internal void ApplyConfiguration(string? localStackHost = null, bool? useSsl = null, bool? useLegacyPorts = null, int? edgePort = null) + { + LocalStackHost = localStackHost ?? LocalStackHost; + UseSsl = useSsl ?? UseSsl; + UseLegacyPorts = useLegacyPorts ?? UseLegacyPorts; + EdgePort = edgePort ?? EdgePort; + } +} diff --git a/src/LocalStack.Client/Options/LocalStackOptions.cs b/src/LocalStack.Client/Options/LocalStackOptions.cs index 0e96aba..9031bbc 100644 --- a/src/LocalStack.Client/Options/LocalStackOptions.cs +++ b/src/LocalStack.Client/Options/LocalStackOptions.cs @@ -24,4 +24,15 @@ public LocalStackOptions(bool useLocalStack, SessionOptions sessionOptions, Conf public SessionOptions Session { get; private set; } public ConfigOptions Config { get; private set; } -} \ No newline at end of file + + internal void ApplyConfiguration(bool? useLocalStack = null, SessionOptions? session = null, ConfigOptions? config = null) + { + if (useLocalStack.HasValue) + { + UseLocalStack = useLocalStack.Value; + } + + Session = session ?? Session; + Config = config ?? Config; + } +} diff --git a/src/LocalStack.Client/Options/SessionOptions.cs b/src/LocalStack.Client/Options/SessionOptions.cs index 8cce43f..df9232d 100644 --- a/src/LocalStack.Client/Options/SessionOptions.cs +++ b/src/LocalStack.Client/Options/SessionOptions.cs @@ -27,4 +27,12 @@ public SessionOptions(string awsAccessKeyId = Constants.AwsAccessKeyId, string a public string AwsSessionToken { get; private set; } = Constants.AwsSessionToken; public string RegionName { get; private set; } = Constants.RegionName; -} \ No newline at end of file + + internal void ApplyConfiguration(string? awsAccessKeyId = null, string? awsAccessKey = null, string? awsSessionToken = null, string? regionName = null) + { + AwsAccessKeyId = awsAccessKeyId ?? AwsAccessKeyId; + AwsAccessKey = awsAccessKey ?? AwsAccessKey; + AwsSessionToken = awsSessionToken ?? AwsSessionToken; + RegionName = regionName ?? RegionName; + } +} diff --git a/src/LocalStack.Client/Properties/AssemblyInfo.cs b/src/LocalStack.Client/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..4da7d9c --- /dev/null +++ b/src/LocalStack.Client/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("LocalStack.Client.Extensions")] diff --git a/src/LocalStack.Client/README.md b/src/LocalStack.Client/README.md index 0d84c47..c9520b1 100644 --- a/src/LocalStack.Client/README.md +++ b/src/LocalStack.Client/README.md @@ -32,10 +32,9 @@ LocalStack.NET is a .NET client library for [LocalStack](https://github.com/loca ## ⚡ Native AOT & Trimming Status -> **Heads‑up for `dotnet publish -p:PublishAot=true` / `PublishTrimmed=true` users** - -- **The current v2 packages do not support Native AOT.** Core client creation still uses reflection over AWS SDK internals, and Extensions' configuration binding uses non-public setters. The AWS-mode wrapper's public SDK factory path does not establish AOT compatibility for the complete application. Respect `[RequiresDynamicCode]` and `[RequiresUnreferencedCode]` annotations where present; do not suppress them to obtain a successful publish. -- The reflection-free source-generator work is in [draft PR #49](https://github.com/localstack-dotnet/localstack-dotnet-client/pull/49), not in `master`. Do not suppress trimming/AOT warnings or adopt an unverified service descriptor on the assumption that this draft has shipped. See the [platform boundary](https://github.com/localstack-dotnet/localstack-dotnet-client/blob/master/docs/architecture/platform-and-packaging.md). +- **Unreleased:** Native AOT support preserves existing C# calls such as `AddLocalStack(configuration)`, `AddAwsService()`, and Core `Session` calls. Source generation flows automatically through either package; no new registration or factory API is required. It roots existing reflection operations rather than removing reflection. +- Local-package consumers have been validated with Native AOT and its mandatory trimming on .NET 10 for `win-x64` and `linux-x64`, including S3 `ListBucketsAsync` and JIT parity for endpoint, region, credentials, and configured values. This does not establish support for every service, operation, or native target, or for separately trimmed JIT deployments. Do not suppress trim/AOT warnings. +- This implementation is **not part of the published Core 2.0.0 / Extensions 2.0.1 releases**. See the [platform boundary](https://github.com/localstack-dotnet/localstack-dotnet-client/blob/master/docs/architecture/platform-and-packaging.md#native-aot-and-trimming) for scope and the [Native AOT decision](https://github.com/localstack-dotnet/localstack-dotnet-client/blob/master/docs/adr/0004-native-aot-preserves-existing-consumer-code.md) for the unchanged-consumer contract. ### Build & Test Matrix diff --git a/src/LocalStack.Client/Session.cs b/src/LocalStack.Client/Session.cs index 72a5c5a..b959ce6 100644 --- a/src/LocalStack.Client/Session.cs +++ b/src/LocalStack.Client/Session.cs @@ -8,6 +8,10 @@ public class Session : ISession private readonly ISessionOptions _sessionOptions; private readonly ISessionReflection _sessionReflection; +#if NET8_0_OR_GREATER + [DynamicDependency(nameof(RegisterGeneratedClient), typeof(Session))] + [DynamicDependency(nameof(RegisterGeneratedConfig), typeof(Session))] +#endif public Session(ISessionOptions sessionOptions, IConfig config, ISessionReflection sessionReflection) { _sessionOptions = sessionOptions; @@ -15,10 +19,6 @@ public Session(ISessionOptions sessionOptions, IConfig config, ISessionReflectio _sessionReflection = sessionReflection; } -#if NET8_0_OR_GREATER - [RequiresDynamicCode("Uses Activator/CreateInstance and private‑field reflection; not safe for Native AOT."), - RequiresUnreferencedCode("Reflection may break when IL trimming removes private members. We’re migrating to a source‑generated path in vNext.")] -#endif public TClient CreateClientByImplementation(bool useServiceUrl = false) where TClient : AmazonServiceClient { Type clientType = typeof(TClient); @@ -26,14 +26,7 @@ public TClient CreateClientByImplementation(bool useServiceUrl = false) return (TClient)CreateClientByImplementation(clientType, useServiceUrl); } -#if NET8_0_OR_GREATER - [RequiresDynamicCode("Uses Activator/CreateInstance and private‑field reflection; not safe for Native AOT."), - RequiresUnreferencedCode("Reflection may break when IL trimming removes private members. We’re migrating to a source‑generated path in vNext.")] -#endif public AmazonServiceClient CreateClientByImplementation( -#if NET8_0_OR_GREATER - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicFields)] -#endif Type implType, bool useServiceUrl = false) { @@ -63,15 +56,11 @@ public AmazonServiceClient CreateClientByImplementation( clientConfig.RegionEndpoint = RegionEndpoint.GetBySystemName(_sessionOptions.RegionName); } - var clientInstance = (AmazonServiceClient)Activator.CreateInstance(implType, awsCredentials, clientConfig); + var clientInstance = CreateImplementation(implType, awsCredentials, clientConfig); return clientInstance; } -#if NET8_0_OR_GREATER - [RequiresDynamicCode("Uses Activator/CreateInstance and private‑field reflection; not safe for Native AOT."), - RequiresUnreferencedCode("Reflection may break when IL trimming removes private members. We’re migrating to a source‑generated path in vNext.")] -#endif public AmazonServiceClient CreateClientByInterface(bool useServiceUrl = false) where TClient : IAmazonService { Type serviceInterfaceType = typeof(TClient); @@ -80,9 +69,6 @@ public AmazonServiceClient CreateClientByInterface(bool useServiceUrl = } public AmazonServiceClient CreateClientByInterface( -#if NET8_0_OR_GREATER - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicFields)] -#endif Type serviceInterfaceType, bool useServiceUrl = false) { @@ -92,7 +78,7 @@ public AmazonServiceClient CreateClientByInterface( } var clientTypeName = $"{serviceInterfaceType.Namespace}.{serviceInterfaceType.Name.Substring(1)}Client"; - Type clientType = serviceInterfaceType.GetTypeInfo().Assembly.GetType(clientTypeName); + Type? clientType = FindClientType(serviceInterfaceType.GetTypeInfo().Assembly, clientTypeName); if (clientType == null) { @@ -127,7 +113,7 @@ public AmazonServiceClient CreateClientByInterface( clientConfig.RegionEndpoint = RegionEndpoint.GetBySystemName(_sessionOptions.RegionName); } - ConstructorInfo? constructor = clientType.GetConstructor(new[] { typeof(AWSCredentials), clientConfig.GetType() }); + ConstructorInfo? constructor = FindClientConstructor(clientType, clientConfig.GetType()); if (constructor == null) { @@ -140,4 +126,75 @@ public AmazonServiceClient CreateClientByInterface( return client; } -} \ No newline at end of file + + private static Type? FindClientType(Assembly assembly, string name) + { +#if NET8_0_OR_GREATER + Type? generated = GeneratedClientRegistry.FindClient(assembly, name); + if (generated != null || !System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported) + { + return generated; + } +#endif + return FindLegacyClientType(assembly, name); + } + + private static ConstructorInfo? FindClientConstructor(Type type, Type configType) + { +#if NET8_0_OR_GREATER + if (GeneratedClientRegistry.TryGetClient(type, out GeneratedClientRegistry.ClientEntry? entry)) + { + return entry.Constructor(configType); + } + + if (!System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported) + { + return null; + } +#endif + return FindLegacyClientConstructor(type, configType); + } + + private static AmazonServiceClient CreateImplementation(Type type, AWSCredentials credentials, ClientConfig config) + { +#if NET8_0_OR_GREATER + if (GeneratedClientRegistry.TryGetClient(type, out GeneratedClientRegistry.ClientEntry? entry)) + { + return entry.Create(credentials, config); + } + + if (!System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported) + { + throw new NotSupportedException($"Client type {type} was not present when the application was compiled."); + } +#endif + return CreateLegacyImplementation(type, credentials, config); + } + +#if NET8_0_OR_GREATER + [RequiresUnreferencedCode("Dynamically loaded client types require their original reflection metadata.")] +#endif + private static Type? FindLegacyClientType(Assembly assembly, string name) => assembly.GetType(name); + +#if NET8_0_OR_GREATER + [RequiresUnreferencedCode("Dynamically loaded client constructors require their original reflection metadata.")] +#endif + private static ConstructorInfo? FindLegacyClientConstructor(Type type, Type configType) => type.GetConstructor(new[] { typeof(AWSCredentials), configType }); + +#if NET8_0_OR_GREATER + [RequiresUnreferencedCode("Dynamically loaded client constructors require their original reflection metadata.")] +#endif + private static AmazonServiceClient CreateLegacyImplementation(Type type, AWSCredentials credentials, ClientConfig config) => + (AmazonServiceClient)Activator.CreateInstance(type, credentials, config)!; + +#if NET8_0_OR_GREATER + // The generated caller uses UnsafeAccessor. Only delegates cross this boundary: no DAM + // requirements are imposed on an accessor signature or on existing consumer Type APIs. + private static void RegisterGeneratedClient(Type type, Func metadata, Func config, + Func constructor, Func create) => + GeneratedClientRegistry.RegisterClient(type, metadata, config, constructor, create); + + private static void RegisterGeneratedConfig(Type type, Func style, Action region) => + GeneratedClientRegistry.RegisterConfig(type, style, region); +#endif +} diff --git a/src/LocalStack.Client/Utils/GeneratedClientRegistry.cs b/src/LocalStack.Client/Utils/GeneratedClientRegistry.cs new file mode 100644 index 0000000..ba4e2b7 --- /dev/null +++ b/src/LocalStack.Client/Utils/GeneratedClientRegistry.cs @@ -0,0 +1,42 @@ +#if NET8_0_OR_GREATER +using System.Collections.Concurrent; + +namespace LocalStack.Client.Utils; + +// Generated initializers only register operations. Metadata, configuration and client creation +// stay lazy, at the original Session/ISessionReflection call sites (including on JIT). +internal static class GeneratedClientRegistry +{ + private static readonly ConcurrentDictionary Clients = new(); + private static readonly ConcurrentDictionary<(Assembly Assembly, string Name), Type> ClientNames = new(); + private static readonly ConcurrentDictionary Configs = new(); + + public static void RegisterClient(Type type, Func metadata, Func config, + Func constructor, Func create) + { + Clients.TryAdd(type, new ClientEntry(metadata, config, constructor, create)); + if (type.FullName is string name) + { + ClientNames.TryAdd((type.Assembly, name), type); + } + } + + public static void RegisterConfig(Type type, Func style, Action region) => + Configs.TryAdd(type, new ConfigEntry(style, region)); + + public static Type? FindClient(Assembly assembly, string name) => ClientNames.TryGetValue((assembly, name), out Type? type) ? type : null; + + public static bool TryGetClient(Type type, [NotNullWhen(true)] out ClientEntry? entry) + { + entry = null; + return type != null && Clients.TryGetValue(type, out entry); + } + + public static bool TryGetConfig(Type type, [NotNullWhen(true)] out ConfigEntry? entry) => Configs.TryGetValue(type, out entry); + + internal sealed record ClientEntry(Func Metadata, Func Config, Func Constructor, + Func Create); + + internal sealed record ConfigEntry(Func Style, Action Region); +} +#endif diff --git a/src/LocalStack.Client/Utils/SessionReflection.cs b/src/LocalStack.Client/Utils/SessionReflection.cs index 7f5b748..09fa78f 100644 --- a/src/LocalStack.Client/Utils/SessionReflection.cs +++ b/src/LocalStack.Client/Utils/SessionReflection.cs @@ -10,22 +10,32 @@ public IServiceMetadata ExtractServiceMetadata() where TClient : Amazon return ExtractServiceMetadata(clientType); } -#if NET8_0_OR_GREATER - [RequiresDynamicCode("Accesses private field 'serviceMetadata' with reflection; not safe for Native AOT."), - RequiresUnreferencedCode("Reflection may break when IL trimming removes private members. We’re migrating to a source‑generated path in vNext.")] -#endif - public IServiceMetadata ExtractServiceMetadata( -#if NET8_0_OR_GREATER - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.NonPublicFields)] -#endif - Type clientType - ) + public IServiceMetadata ExtractServiceMetadata(Type clientType) { if (clientType == null) { throw new ArgumentNullException(nameof(clientType)); } +#if NET8_0_OR_GREATER + if (GeneratedClientRegistry.TryGetClient(clientType, out GeneratedClientRegistry.ClientEntry? entry)) + { + return entry.Metadata(); + } + + if (!System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported) + { + throw new InvalidOperationException($"Invalid service type {clientType}"); + } +#endif + return ExtractLegacyServiceMetadata(clientType); + } + +#if NET8_0_OR_GREATER + [RequiresUnreferencedCode("Dynamically loaded client metadata requires its original private fields.")] +#endif + private static IServiceMetadata ExtractLegacyServiceMetadata(Type clientType) + { FieldInfo serviceMetadataField = clientType.GetField("serviceMetadata", BindingFlags.Static | BindingFlags.NonPublic) ?? throw new InvalidOperationException($"Invalid service type {clientType}"); @@ -42,67 +52,97 @@ public ClientConfig CreateClientConfig() where TClient : AmazonServiceC return CreateClientConfig(clientType); } -#if NET8_0_OR_GREATER - [RequiresDynamicCode("Uses Activator.CreateInstance on derived ClientConfig types; not safe for Native AOT."), - RequiresUnreferencedCode("Reflection may break when IL trimming removes private members. We’re migrating to a source‑generated path in vNext.")] -#endif - public ClientConfig CreateClientConfig( -#if NET8_0_OR_GREATER - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] -#endif - Type clientType - - ) + public ClientConfig CreateClientConfig(Type clientType) { if (clientType == null) { throw new ArgumentNullException(nameof(clientType)); } +#if NET8_0_OR_GREATER + if (GeneratedClientRegistry.TryGetClient(clientType, out GeneratedClientRegistry.ClientEntry? entry)) + { + return entry.Config(); + } + + if (!System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported) + { + throw new InvalidOperationException("Sequence contains no matching element"); + } +#endif + return CreateLegacyClientConfig(clientType); + } + +#if NET8_0_OR_GREATER + [RequiresUnreferencedCode("Dynamically loaded configurations require their original constructors.")] +#endif + private static ClientConfig CreateLegacyClientConfig(Type clientType) + { ConstructorInfo clientConstructorInfo = FindConstructorWithCredentialsAndClientConfig(clientType); ParameterInfo clientConfigParam = clientConstructorInfo.GetParameters()[1]; return (ClientConfig)Activator.CreateInstance(clientConfigParam.ParameterType); } -#if NET8_0_OR_GREATER - [RequiresDynamicCode("Reflects over Config.RegionEndpoint property; not safe for Native AOT."), - RequiresUnreferencedCode("Reflection may break when IL trimming removes private members. We’re migrating to a source‑generated path in vNext.")] -#endif - public void SetClientRegion( -#if NET8_0_OR_GREATER - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] -#endif - AmazonServiceClient amazonServiceClient, - string systemName) + public void SetClientRegion(AmazonServiceClient amazonServiceClient, string systemName) { if (amazonServiceClient == null) { throw new ArgumentNullException(nameof(amazonServiceClient)); } +#if NET8_0_OR_GREATER + if (GeneratedClientRegistry.TryGetConfig(amazonServiceClient.Config.GetType(), out GeneratedClientRegistry.ConfigEntry? entry)) + { + entry.Region(amazonServiceClient.Config, systemName); + return; + } + + if (!System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported) + { + throw new NotSupportedException($"Configuration type {amazonServiceClient.Config.GetType()} was not present when the application was compiled."); + } +#endif + SetLegacyClientRegion(amazonServiceClient, systemName); + } + +#if NET8_0_OR_GREATER + [RequiresUnreferencedCode("Dynamically loaded configurations require their original properties.")] +#endif + private static void SetLegacyClientRegion(AmazonServiceClient amazonServiceClient, string systemName) + { PropertyInfo? regionEndpointProperty = amazonServiceClient.Config.GetType() .GetProperty(nameof(amazonServiceClient.Config.RegionEndpoint), BindingFlags.Public | BindingFlags.Instance); regionEndpointProperty?.SetValue(amazonServiceClient.Config, RegionEndpoint.GetBySystemName(systemName)); } -#if NET8_0_OR_GREATER - [RequiresDynamicCode("Reflects over ForcePathStyle property; not safe for Native AOT."), - RequiresUnreferencedCode("Reflection may break when IL trimming removes private members. We’re migrating to a source‑generated path in vNext.")] -#endif - public bool SetForcePathStyle( -#if NET8_0_OR_GREATER - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] -#endif - ClientConfig clientConfig, - bool value = true) + public bool SetForcePathStyle(ClientConfig clientConfig, bool value = true) { if (clientConfig == null) { throw new ArgumentNullException(nameof(clientConfig)); } +#if NET8_0_OR_GREATER + if (GeneratedClientRegistry.TryGetConfig(clientConfig.GetType(), out GeneratedClientRegistry.ConfigEntry? entry)) + { + return entry.Style(clientConfig, value); + } + + if (!System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported) + { + throw new NotSupportedException($"Configuration type {clientConfig.GetType()} was not present when the application was compiled."); + } +#endif + return SetLegacyForcePathStyle(clientConfig, value); + } + +#if NET8_0_OR_GREATER + [RequiresUnreferencedCode("Dynamically loaded configurations require their original properties.")] +#endif + private static bool SetLegacyForcePathStyle(ClientConfig clientConfig, bool value) + { PropertyInfo? forcePathStyleProperty = clientConfig.GetType().GetProperty("ForcePathStyle", BindingFlags.Public | BindingFlags.Instance); if (forcePathStyleProperty == null) @@ -115,6 +155,9 @@ public bool SetForcePathStyle( return true; } +#if NET8_0_OR_GREATER + [RequiresUnreferencedCode("Dynamically loaded clients require their original constructors.")] +#endif private static ConstructorInfo FindConstructorWithCredentialsAndClientConfig(Type clientType) { return clientType.GetConstructors(BindingFlags.Instance | BindingFlags.Public) @@ -136,4 +179,4 @@ private static ConstructorInfo FindConstructorWithCredentialsAndClientConfig(Typ clientConfigParameter.ParameterType.IsSubclassOf(typeof(ClientConfig)); }); } -} \ No newline at end of file +} diff --git a/src/LocalStack.Client/buildTransitive/LocalStack.Client.targets b/src/LocalStack.Client/buildTransitive/LocalStack.Client.targets new file mode 100644 index 0000000..91f8f27 --- /dev/null +++ b/src/LocalStack.Client/buildTransitive/LocalStack.Client.targets @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/Directory.Build.targets b/tests/Directory.Build.targets new file mode 100644 index 0000000..1d11f3a --- /dev/null +++ b/tests/Directory.Build.targets @@ -0,0 +1,6 @@ + + + + + diff --git a/tests/LocalStack.Client.Extensions.Tests/OptionsBindingCompatibilityTests.cs b/tests/LocalStack.Client.Extensions.Tests/OptionsBindingCompatibilityTests.cs new file mode 100644 index 0000000..5a74745 --- /dev/null +++ b/tests/LocalStack.Client.Extensions.Tests/OptionsBindingCompatibilityTests.cs @@ -0,0 +1,246 @@ +namespace LocalStack.Client.Extensions.Tests; + +public class OptionsBindingCompatibilityTests +{ + [Fact] + public void Binding_is_lazy_and_preserves_independent_nested_options() + { + IConfigurationRoot configuration = Configuration(new Dictionary(StringComparer.Ordinal) + { + ["localstack:uselocalstack"] = "true", + ["LocalStack:Session:AwsAccessKeyId"] = "fixture-id", + ["LocalStack:Session:AwsAccessKey"] = "fixture-secret", + ["LocalStack:Session:AwsSessionToken"] = "fixture-token", + ["LocalStack:Session:RegionName"] = "eu-west-2", + ["LocalStack:Config:LocalStackHost"] = "fixture-host", + ["LocalStack:Config:UseSsl"] = "true", + ["LocalStack:Config:UseLegacyPorts"] = "true", + ["LocalStack:Config:EdgePort"] = "0x1234", + ["LocalStack:Ignored"] = "ignored", + }); + var services = new ServiceCollection(); + services.AddLocalStack(configuration); + configuration["LocalStack:Session:RegionName"] = "eu-north-1"; + using ServiceProvider provider = services.BuildServiceProvider(); + LocalStackOptions parent = provider.GetRequiredService>().Value; + SessionOptions session = provider.GetRequiredService>().Value; + ConfigOptions config = provider.GetRequiredService>().Value; + + Assert.True(parent.UseLocalStack); + Assert.NotSame(parent.Session, session); + Assert.NotSame(parent.Config, config); + foreach (SessionOptions value in new[] { parent.Session, session }) + { + Assert.Equal("fixture-id", value.AwsAccessKeyId); + Assert.Equal("fixture-secret", value.AwsAccessKey); + Assert.Equal("fixture-token", value.AwsSessionToken); + Assert.Equal("eu-north-1", value.RegionName); + } + + foreach (ConfigOptions value in new[] { parent.Config, config }) + { + Assert.Equal("fixture-host", value.LocalStackHost); + Assert.True(value.UseSsl); + Assert.True(value.UseLegacyPorts); + Assert.Equal(4660, value.EdgePort); + } + + configuration["LocalStack:Session:RegionName"] = "us-west-1"; + Assert.Same(session, provider.GetRequiredService>().Value); + Assert.Equal("eu-north-1", session.RegionName); + using IServiceScope scope = provider.CreateScope(); + Assert.Equal("us-west-1", scope.ServiceProvider.GetRequiredService>().Value.RegionName); + } + + [Theory] + [InlineData("0x10", 16)] + [InlineData("&h10", 16)] + [InlineData("#10", 16)] + [InlineData(" +17 ", 17)] + [InlineData("-17", -17)] + public void Integers_use_the_invariant_converter(string text, int expected) + { + CultureInfo previous = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("tr-TR"); + var services = new ServiceCollection(); + services.AddLocalStack(Configuration(new Dictionary(StringComparer.Ordinal) { ["LocalStack:Config:EdgePort"] = text })); + using ServiceProvider provider = services.BuildServiceProvider(); + Assert.Equal(expected, provider.GetRequiredService>().Value.EdgePort); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } + + [Theory] + [InlineData("LocalStack:Config:EdgePort", "", typeof(int))] + [InlineData("LocalStack:Config:EdgePort", "2147483648", typeof(int))] + [InlineData("LocalStack:Config:EdgePort", "1,2", typeof(int))] + [InlineData("LocalStack:Config:UseSsl", "", typeof(bool))] + [InlineData("LocalStack:Config:UseSsl", "yes", typeof(bool))] + public void Conversion_errors_retain_path_type_and_converter_exception(string path, string text, Type type) + { + var services = new ServiceCollection(); + services.AddLocalStack(Configuration(new Dictionary(StringComparer.Ordinal) { [path] = text })); + using ServiceProvider provider = services.BuildServiceProvider(); + + InvalidOperationException error = Assert.Throws(() => provider.GetRequiredService>().Value); + Assert.Equal($"Failed to convert configuration value at '{path}' to type '{type}'.", error.Message); + var converter = type == typeof(int) ? (System.ComponentModel.TypeConverter)new System.ComponentModel.Int32Converter() : new System.ComponentModel.BooleanConverter(); + Exception expected = Assert.ThrowsAny(() => converter.ConvertFromInvariantString(text)); + Assert.NotNull(error.InnerException); + Assert.Equal(expected.GetType(), error.InnerException.GetType()); + Assert.Equal(expected.Message, error.InnerException.Message); + Assert.Equal(expected.InnerException?.GetType(), error.InnerException.InnerException?.GetType()); + } + + [Fact] + public void Missing_null_and_child_only_scalars_preserve_prior_values_but_empty_strings_bind() + { + var services = new ServiceCollection(); + services.Configure(options => SetPrivate(options, nameof(SessionOptions.RegionName), "earlier")); + services.Configure(options => SetPrivate(options, nameof(ConfigOptions.EdgePort), 1234)); + services.AddLocalStack(Configuration(new Dictionary(StringComparer.Ordinal) + { + ["LocalStack:Session:RegionName"] = null, + ["LocalStack:Session:AwsAccessKey"] = "", + ["LocalStack:Config:EdgePort:Child"] = "ignored", + ["LocalStack:Config:UseSsl"] = " tRuE ", + })); + using ServiceProvider provider = services.BuildServiceProvider(); + SessionOptions session = provider.GetRequiredService>().Value; + ConfigOptions config = provider.GetRequiredService>().Value; + Assert.Equal("earlier", session.RegionName); + Assert.Equal("", session.AwsAccessKey); + Assert.Equal(new SessionOptions().AwsAccessKeyId, session.AwsAccessKeyId); + Assert.Equal(1234, config.EdgePort); + Assert.True(config.UseSsl); + } + + [Fact] + public void Configure_postconfigure_validation_and_monitor_keep_framework_order() + { + var observed = new List(); + var services = new ServiceCollection(); + services.Configure(options => observed.Add("before:" + options.RegionName)); + services.AddLocalStack(Configuration(new Dictionary(StringComparer.Ordinal) { ["LocalStack:Session:RegionName"] = "bound" })); + services.Configure(options => observed.Add("after:" + options.RegionName)); + services.PostConfigure(options => observed.Add("post:" + options.RegionName)); + services.AddSingleton>(new RegionValidator()); + using ServiceProvider provider = services.BuildServiceProvider(); + Assert.Equal("bound", provider.GetRequiredService>().CurrentValue.RegionName); + Assert.Equal(new[] { "before:" + new SessionOptions().RegionName, "after:bound", "post:bound" }, observed, StringComparer.Ordinal); + observed.Clear(); + OptionsValidationException error = Assert.Throws(() => provider.GetRequiredService>().Get("other")); + Assert.Equal("region must be bound", Assert.Single(error.Failures)); + Assert.Empty(observed); + } + + [Fact] + public void Missing_sections_retain_all_defaults() + { + var services = new ServiceCollection(); + services.AddLocalStack(Configuration(new Dictionary(StringComparer.Ordinal))); + using ServiceProvider provider = services.BuildServiceProvider(); + LocalStackOptions parent = provider.GetRequiredService>().Value; + SessionOptions session = provider.GetRequiredService>().Value; + ConfigOptions config = provider.GetRequiredService>().Value; + Assert.False(parent.UseLocalStack); + Assert.Equal(new SessionOptions().AwsAccessKeyId, session.AwsAccessKeyId); + Assert.Equal(new SessionOptions().AwsAccessKey, session.AwsAccessKey); + Assert.Equal(new SessionOptions().AwsSessionToken, session.AwsSessionToken); + Assert.Equal(new SessionOptions().RegionName, session.RegionName); + Assert.Equal(new ConfigOptions().LocalStackHost, config.LocalStackHost); + Assert.Equal(new ConfigOptions().EdgePort, config.EdgePort); + Assert.Equal(new ConfigOptions().UseSsl, config.UseSsl); + Assert.Equal(new ConfigOptions().UseLegacyPorts, config.UseLegacyPorts); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Previously_null_nested_options_are_created_only_when_the_section_has_children(bool hasChildren) + { + IConfigurationRoot configuration = Configuration(hasChildren + ? new Dictionary(StringComparer.Ordinal) { ["LocalStack:Session:RegionName"] = "bound", ["LocalStack:Config:UseSsl"] = "true" } + : new Dictionary(StringComparer.Ordinal)); + // Characterize the pinned general binder as well as the public registration seam. + foreach (bool originalBinder in new[] { true, false }) + { + var services = new ServiceCollection(); + services.Configure(options => + { + SetPrivate(options, nameof(LocalStackOptions.Session), null); + SetPrivate(options, nameof(LocalStackOptions.Config), null); + }); + if (originalBinder) + { + services.Configure(options => configuration.GetSection("LocalStack").Bind(options, binding => binding.BindNonPublicProperties = true)); + } + else + { + services.AddLocalStack(configuration); + } + + using ServiceProvider provider = services.BuildServiceProvider(); + LocalStackOptions parent = provider.GetRequiredService>().Value; + if (hasChildren) + { + Assert.Equal("bound", parent.Session.RegionName); + Assert.True(parent.Config.UseSsl); + } + else + { + Assert.Null(parent.Session); + Assert.Null(parent.Config); + } + } + } + + [Fact] + public void A_failed_new_nested_binding_does_not_publish_the_partially_bound_object() + { + IConfigurationRoot configuration = Configuration(new Dictionary(StringComparer.Ordinal) + { + ["LocalStack:Config:UseSsl"] = "true", + ["LocalStack:Config:EdgePort"] = "invalid", + }); + foreach (bool originalBinder in new[] { true, false }) + { + LocalStackOptions? captured = null; + var services = new ServiceCollection(); + services.Configure(options => + { + captured = options; + SetPrivate(options, nameof(LocalStackOptions.Config), null); + }); + if (originalBinder) + { + services.Configure(options => configuration.GetSection("LocalStack").Bind(options, binding => binding.BindNonPublicProperties = true)); + } + else + { + services.AddLocalStack(configuration); + } + + using ServiceProvider provider = services.BuildServiceProvider(); + Assert.Throws(() => provider.GetRequiredService>().Value); + Assert.NotNull(captured); + Assert.Null(captured.Config); + } + } + + private static IConfigurationRoot Configuration(Dictionary values) => new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + + private static void SetPrivate(object options, string property, object? value) => options.GetType().GetProperty(property)!.SetValue(options, value); + + private sealed class RegionValidator : IValidateOptions + { + public ValidateOptionsResult Validate(string? name, SessionOptions options) => options.RegionName == "bound" + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail("region must be bound"); + } +} diff --git a/tests/LocalStack.Client.Generators.Tests/GeneratorTests.cs b/tests/LocalStack.Client.Generators.Tests/GeneratorTests.cs new file mode 100644 index 0000000..eafc082 --- /dev/null +++ b/tests/LocalStack.Client.Generators.Tests/GeneratorTests.cs @@ -0,0 +1,327 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Loader; +using Amazon.Runtime; +using Amazon.S3; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace LocalStack.Client.Generators.Tests; + +public class GeneratorTests +{ + [Fact] + public void ReferencedSdkIsDiscoveredWithoutRegistrationSyntax() + { + (GeneratorDriverRunResult result, Compilation output) = Generate("internal class Consumer { }"); + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + AssertNoCompilationErrors(output); + // Emit and inspect the generated assembly, not a marker that can pass without SDK roots. + using var stream = new MemoryStream(); + Assert.True(output.Emit(stream).Success); + Assembly assembly = Assembly.Load(stream.ToArray()); + Type registry = Assert.Single(assembly.GetTypes(), type => string.Equals(type.Name, "LocalStackGeneratedRegistry", StringComparison.Ordinal)); + Assert.Contains(registry.GetMethods(BindingFlags.NonPublic | BindingFlags.Static), method => method.Name.StartsWith("Metadata", StringComparison.Ordinal)); + } + + [Fact] + public void ConsumerGlobalImportsCannotChangeGeneratedTypeIdentity() + { + (_, Compilation output) = Generate("global using Collision; namespace Collision { internal class Type { } }"); + AssertNoCompilationErrors(output); + } + + [Theory] + [InlineData("internal class Func { } internal class Action { }")] + [InlineData("internal delegate T Func(); internal delegate T Func(T1 a); internal delegate T Func(T1 a,T2 b); internal delegate void Action(T1 a,T2 b);")] + [InlineData(""" + internal sealed class ModuleInitializerAttribute : System.Attribute { } + internal sealed class UnsafeAccessorAttribute : System.Attribute { + public UnsafeAccessorAttribute(UnsafeAccessorKind kind) { } + public string Name { get; set; } + } + internal enum UnsafeAccessorKind { StaticMethod } + internal sealed class DynamicallyAccessedMembersAttribute : System.Attribute { + public DynamicallyAccessedMembersAttribute(DynamicallyAccessedMemberTypes types) { } + } + internal enum DynamicallyAccessedMemberTypes { NonPublicFields, PublicConstructors, PublicProperties } + """)] + public void ConsumerGlobalDelegateAndAttributeNamesCannotBreakGeneratedCompilation(string declarations) + { + (_, Compilation output) = Generate("global using Collision; namespace Collision { " + declarations + " }"); + AssertNoCompilationErrors(output); + using var stream = new MemoryStream(); + Assert.True(output.Emit(stream).Success); + } + + [Fact] + public void PrivateClosedGenericClientsRemainLazyAndUseTheirOwnMetadataAndConfig() + { + (_, Compilation output) = Generate(PrivateGenericFixture); + AssertNoCompilationErrors(output); + Assembly assembly = Load(output); + Type fixture = assembly.GetType("Fixture", throwOnError: true)!; + Type clientType = (Type)fixture.GetProperty("ClientType")!.GetValue(null)!; + // The behavior assertions below must not pass via the ordinary JIT fallback. + Type registry = typeof(Session).Assembly.GetType("LocalStack.Client.Utils.GeneratedClientRegistry", throwOnError: true)!; + object?[] lookup = { clientType, null }; + Assert.True((bool)registry.GetMethod("TryGetClient")!.Invoke(null, lookup)!); + Assert.Equal(0, fixture.GetField("MetadataCalls")!.GetValue(null)); + Assert.Equal(0, fixture.GetField("ConfigCalls")!.GetValue(null)); + var reflection = new Utils.SessionReflection(); + Assert.Equal("DynamoDB", reflection.ExtractServiceMetadata(clientType).ServiceId); + Assert.Equal(1, fixture.GetField("MetadataCalls")!.GetValue(null)); + ClientConfig config = reflection.CreateClientConfig(clientType); + Assert.Equal((Type)fixture.GetProperty("ConfigType")!.GetValue(null)!, config.GetType()); + Assert.Equal(1, fixture.GetField("ConfigCalls")!.GetValue(null)); + Assert.True(reflection.SetForcePathStyle(config)); + var session = new Session(new Options.SessionOptions("fixture-id", "fixture-secret", "fixture-token", "eu-west-2"), + new Config(new Options.ConfigOptions(localStackHost: "fixture-host", useLegacyPorts: true)), reflection); + using AmazonServiceClient client = session.CreateClientByImplementation(clientType); + Assert.Equal(clientType, client.GetType()); + Assert.Equal(4569, client.Config.ProxyPort); + Assert.Equal("fixture-host", client.Config.ProxyHost); + Assert.Equal("eu-west-2", client.Config.RegionEndpoint.SystemName); + } + + [Fact] + public void MultipleInitializersDoNotEvaluateClientOrConfigOperations() + { + (_, Compilation output) = Generate(PrivateGenericFixture); + Assembly assembly = Load(output); + Type registry = assembly.GetType("LocalStack.Client.Generated.LocalStackGeneratedRegistry", throwOnError: true)!; + MethodInfo initialize = registry.GetMethod("Initialize", BindingFlags.Static | BindingFlags.NonPublic)!; + initialize.Invoke(null, null); + initialize.Invoke(null, null); + Type fixture = assembly.GetType("Fixture", throwOnError: true)!; + Assert.Equal(0, fixture.GetField("MetadataCalls")!.GetValue(null)); + Assert.Equal(0, fixture.GetField("ConfigCalls")!.GetValue(null)); + } + + [Fact] + public void ReferencedConfigsWithTheSameFullNameKeepActualAssemblyIdentity() + { + const string configSource = "namespace Shared { public class Config : Amazon.S3.AmazonS3Config { } }"; + Compilation first = CreateCompilation(configSource); + Compilation second = CreateCompilation(configSource); + Assembly firstAssembly = Load(first); + Assembly secondAssembly = Load(second); + PortableExecutableReference Reference(Compilation compilation, string alias) + { + using var stream = new MemoryStream(); + Assert.True(compilation.Emit(stream).Success); + return MetadataReference.CreateFromImage(stream.ToArray(), new MetadataReferenceProperties(aliases: ImmutableArray.Create(alias))); + } + + (_, Compilation output) = Generate(""" + extern alias First; + extern alias Second; + public static class Consumer { + public static System.Type FirstType => typeof(First::Shared.Config); + public static System.Type SecondType => typeof(Second::Shared.Config); + } + """, Reference(first, "First"), Reference(second, "Second")); + AssertNoCompilationErrors(output); + Type consumer = Load(output).GetType("Consumer", throwOnError: true)!; + Type firstType = (Type)consumer.GetProperty("FirstType")!.GetValue(null)!; + Type secondType = (Type)consumer.GetProperty("SecondType")!.GetValue(null)!; + Assert.Equal(firstAssembly, firstType.Assembly); + Assert.Equal(secondAssembly, secondType.Assembly); + Assert.NotEqual(firstType, secondType); + Type registry = typeof(Session).Assembly.GetType("LocalStack.Client.Utils.GeneratedClientRegistry", throwOnError: true)!; + foreach (Type type in new[] { firstType, secondType }) + { + object?[] lookup = { type, null }; + Assert.True((bool)registry.GetMethod("TryGetConfig")!.Invoke(null, lookup)!); + var config = (ClientConfig)Activator.CreateInstance(type)!; + Assert.True(new Utils.SessionReflection().SetForcePathStyle(config)); + Assert.True(((AmazonS3Config)config).ForcePathStyle); + } + } + + [Fact] + public void InaccessibleReferencedConfigsAreDiscoveredWithoutConsumerTypeSyntax() + { + Compilation library = CreateCompilation(""" + public static class Factory { + public static Amazon.Runtime.ClientConfig Create() => new PrivateConfig(); + private sealed class PrivateConfig : Amazon.S3.AmazonS3Config { } + } + """); + Load(library); + using var stream = new MemoryStream(); + Assert.True(library.Emit(stream).Success); + (_, Compilation output) = Generate("public static class Consumer { public static object Create() => Factory.Create(); }", + MetadataReference.CreateFromImage(stream.ToArray())); + AssertNoCompilationErrors(output); + var config = (ClientConfig)Load(output).GetType("Consumer", throwOnError: true)!.GetMethod("Create")!.Invoke(null, null)!; + Type registry = typeof(Session).Assembly.GetType("LocalStack.Client.Utils.GeneratedClientRegistry", throwOnError: true)!; + object?[] lookup = { config.GetType(), null }; + Assert.True((bool)registry.GetMethod("TryGetConfig")!.Invoke(null, lookup)!); + Assert.True(new Utils.SessionReflection().SetForcePathStyle(config)); + Assert.True(((AmazonS3Config)config).ForcePathStyle); + } + + [Fact] + public void ReferencedClosedSignaturesAreRegisteredWithoutConsumerGenericSyntaxAndRemainLazy() + { + Compilation library = CreateCompilation(ReferencedSignatureFixture); + Assembly libraryAssembly = Load(library); + Assert.DoesNotContain(libraryAssembly.GetReferencedAssemblies(), name => name.Name!.StartsWith("LocalStack", StringComparison.Ordinal)); + using var stream = new MemoryStream(); + Assert.True(library.Emit(stream).Success); + (_, Compilation output) = Generate("public static class Consumer { public static object Create() => Factory.Create(); }", + MetadataReference.CreateFromImage(stream.ToArray())); + AssertNoCompilationErrors(output); + Assembly consumer = Load(output); + // Trigger the generated module initializer, not the fixture factory or its type initializers. + System.Runtime.CompilerServices.RuntimeHelpers.RunModuleConstructor(consumer.ManifestModule.ModuleHandle); + Type registry = typeof(Session).Assembly.GetType("LocalStack.Client.Utils.GeneratedClientRegistry", throwOnError: true)!; + Type calls = libraryAssembly.GetType("Calls", throwOnError: true)!; + Type clientDefinition = libraryAssembly.GetType("MyClient`1", throwOnError: true)!; + Type configDefinition = libraryAssembly.GetType("MyConfig`1", throwOnError: true)!; + Type[] arguments = { typeof(string), typeof(byte), typeof(int), typeof(short), typeof(long) }; + foreach (Type argument in arguments) + { + object?[] lookup = { clientDefinition.MakeGenericType(argument), null }; + Assert.True((bool)registry.GetMethod("TryGetClient")!.Invoke(null, lookup)!); + object?[] configLookup = { configDefinition.MakeGenericType(argument), null }; + Assert.True((bool)registry.GetMethod("TryGetConfig")!.Invoke(null, configLookup)!); + } + + object?[] signatureConfigLookup = { configDefinition.MakeGenericType(typeof(decimal)), null }; + Assert.True((bool)registry.GetMethod("TryGetConfig")!.Invoke(null, signatureConfigLookup)!); + Assert.Equal(0, calls.GetField("Metadata")!.GetValue(null)); + Assert.Equal(0, calls.GetField("Config")!.GetValue(null)); + var reflection = new Utils.SessionReflection(); + var session = new Session(new Options.SessionOptions("fixture-id", "fixture-secret", "fixture-token", "eu-west-2"), + new Config(new Options.ConfigOptions()), reflection); + foreach (Type argument in arguments) + { + Type clientType = clientDefinition.MakeGenericType(argument); + Assert.Equal("S3", reflection.ExtractServiceMetadata(clientType).ServiceId); + using AmazonServiceClient client = session.CreateClientByImplementation(clientType); + Assert.Equal(clientType, client.GetType()); + Assert.Equal(configDefinition.MakeGenericType(argument), client.Config.GetType()); + Assert.Equal("eu-west-2", client.Config.RegionEndpoint.SystemName); + Assert.True(((AmazonS3Config)client.Config).ForcePathStyle); + } + + Assert.Equal(arguments.Length, calls.GetField("Metadata")!.GetValue(null)); + Assert.Equal(arguments.Length, calls.GetField("Config")!.GetValue(null)); + } + + [Fact] + public void UnconstrainedTypeConfigCreationDoesNotRequireAClientBaseClassOrMetadata() + { + (_, Compilation output) = Generate(""" + public class Ordinary { + public Ordinary(Amazon.Runtime.AWSCredentials credentials, Amazon.S3.AmazonS3Config clientConfig) { } + } + public static class Consumer { public static System.Type ClientType => typeof(Ordinary); } + """); + AssertNoCompilationErrors(output); + Type consumer = Load(output).GetType("Consumer", throwOnError: true)!; + Type type = (Type)consumer.GetProperty("ClientType")!.GetValue(null)!; + Type registry = typeof(Session).Assembly.GetType("LocalStack.Client.Utils.GeneratedClientRegistry", throwOnError: true)!; + object?[] lookup = { type, null }; + Assert.True((bool)registry.GetMethod("TryGetClient")!.Invoke(null, lookup)!); + Assert.IsType(new Utils.SessionReflection().CreateClientConfig(type)); + Assert.Throws(() => new Utils.SessionReflection().ExtractServiceMetadata(type)); + } + + private static Assembly Load(Compilation compilation) + { + using var stream = new MemoryStream(); + Assert.True(compilation.Emit(stream).Success); + stream.Position = 0; + return AssemblyLoadContext.Default.LoadFromStream(stream); + } + + private const string ReferencedSignatureFixture = """ + using System; + using System.Collections.Generic; + using Amazon.Runtime; + using Amazon.Runtime.Internal; + public static class Calls { public static int Metadata; public static int Config; } + public class MyConfig : Amazon.S3.AmazonS3Config { + public MyConfig() { Calls.Config++; } + } + public class MyClient : AmazonServiceClient { + private static readonly IServiceMetadata serviceMetadata = new Metadata(); + public static IServiceMetadata MetadataValue => serviceMetadata; + public MyClient(AWSCredentials credentials, MyConfig clientConfig) : base(credentials, clientConfig) { } + } + public static class Factory { + public static MyClient Create() => throw new NotImplementedException(); + public static void Accept(MyClient value) { } + public static List[]> Property => throw new NotImplementedException(); + public static MyClient[] Field; + public static List[]> Configs => throw new NotImplementedException(); + } + public sealed class FromBase : MyClient { + public FromBase(AWSCredentials credentials, MyConfig clientConfig) : base(credentials, clientConfig) { } + } + internal sealed class Metadata : IServiceMetadata { + public Metadata() { Calls.Metadata++; } + public string ServiceId => "S3"; + public IDictionary OperationNameMapping { get; } = new Dictionary(); + } + """; + + private const string PrivateGenericFixture = """ + using System; + using System.Collections.Generic; + using Amazon.Runtime; + using Amazon.Runtime.Internal; + using Amazon.S3; + public static class Fixture { + public static int MetadataCalls; + public static int ConfigCalls; + public static Type ClientType => typeof(Client); + public static Type ConfigType => typeof(Config); + private sealed class Config : AmazonS3Config { + public Config() { ConfigCalls++; } + } + private sealed class Client : AmazonServiceClient { + private static readonly IServiceMetadata serviceMetadata = new Metadata(); + public static IServiceMetadata MetadataValue => serviceMetadata; + public Client(AWSCredentials credentials, Config clientConfig) : base(credentials, clientConfig) { } + } + private sealed class Metadata : IServiceMetadata { + public Metadata() { MetadataCalls++; } + public string ServiceId => "DynamoDB"; + public IDictionary OperationNameMapping { get; } = new Dictionary(StringComparer.Ordinal); + } + } + """; + + private static (GeneratorDriverRunResult Result, Compilation Output) Generate(string source, params PortableExecutableReference[] additionalReferences) + { + CSharpCompilation compilation = CreateCompilation(source, additionalReferences); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new AwsRegistryGenerator().AsSourceGenerator()); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out Compilation output, out ImmutableArray diagnostics); + Assert.Empty(diagnostics); + return (driver.GetRunResult(), output); + } + + private static CSharpCompilation CreateCompilation(string source, params PortableExecutableReference[] additionalReferences) + { + var paths = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator).ToHashSet(StringComparer.OrdinalIgnoreCase); + paths.Add(typeof(Session).Assembly.Location); + paths.Add(typeof(AmazonServiceClient).Assembly.Location); + paths.Add(typeof(AmazonS3Client).Assembly.Location); + IEnumerable references = paths.Select(path => MetadataReference.CreateFromFile(path)); + return CSharpCompilation.Create("GeneratedConsumer" + Guid.NewGuid().ToString("N"), + new[] { CSharpSyntaxTree.ParseText(source) }, references.Concat(additionalReferences), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } + + private static void AssertNoCompilationErrors(Compilation compilation) => Assert.Empty(compilation.GetDiagnostics().Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)); +} diff --git a/tests/LocalStack.Client.Generators.Tests/LocalStack.Client.Generators.Tests.csproj b/tests/LocalStack.Client.Generators.Tests/LocalStack.Client.Generators.Tests.csproj new file mode 100644 index 0000000..8c86da1 --- /dev/null +++ b/tests/LocalStack.Client.Generators.Tests/LocalStack.Client.Generators.Tests.csproj @@ -0,0 +1,17 @@ + + + net10.0 + false + + + + + + + + + + + + + diff --git a/tests/LocalStack.Client.Tests/SessionTests/SessionCompatibilityTests.cs b/tests/LocalStack.Client.Tests/SessionTests/SessionCompatibilityTests.cs new file mode 100644 index 0000000..accb9da --- /dev/null +++ b/tests/LocalStack.Client.Tests/SessionTests/SessionCompatibilityTests.cs @@ -0,0 +1,223 @@ +using LocalStack.Client.Contracts; + +namespace LocalStack.Client.Tests.SessionTests; + +public class SessionCompatibilityTests +{ + private static readonly string[] ExpectedCalls = { "metadata", "config", "style" }; + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Injected_reflection_controls_metadata_config_style_and_credentials(bool byInterface) + { + var reflection = new RecordingReflection(); + var session = new Session(new SessionOptions("fixture-id", "fixture-secret", "fixture-token", "eu-west-2"), + new Config(new ConfigOptions(localStackHost: "fixture-host", useLegacyPorts: true)), reflection); + using var client = (MockAmazonServiceClient)(byInterface + ? session.CreateClientByInterface() + : session.CreateClientByImplementation()); + + Assert.Equal(ExpectedCalls, reflection.Calls, StringComparer.Ordinal); + Assert.Same(reflection.Config, client.Config); + Assert.Equal(TimeSpan.FromSeconds(42), client.Config.Timeout); + Assert.Equal(4569, client.Config.ProxyPort); + Assert.Equal("fixture-host", client.Config.ProxyHost); + Assert.Equal("eu-west-2", client.Config.RegionEndpoint.SystemName); + Assert.True(client.Config.UseHttp); + Assert.False(reflection.Config.ForcePathStyle); + ImmutableCredentials credentials = client.AwsCredentials.GetCredentials(); + Assert.Equal("fixture-id", credentials.AccessKey); + Assert.Equal("fixture-secret", credentials.SecretKey); + Assert.Equal("fixture-token", credentials.Token); + } + + [Fact] + public void Region_validation_precedes_injected_metadata_for_implementation_calls() + { + var reflection = new RecordingReflection(); + var session = new Session(new SessionOptions(regionName: ""), new Config(new ConfigOptions()), reflection); + Assert.Throws(() => session.CreateClientByImplementation()); + Assert.Empty(reflection.Calls); + // Interface convention lookup still happens before region validation. + Assert.Throws(() => session.CreateClientByInterface(typeof(ISession))); + Assert.Empty(reflection.Calls); + } + + [Fact] + public void Injected_failure_identity_and_no_later_calls_are_preserved() + { + var reflection = new RecordingReflection { Failure = new InvalidOperationException("fixture metadata failure") }; + var session = new Session(new SessionOptions(), new Config(new ConfigOptions()), reflection); + InvalidOperationException error = Assert.Throws(() => session.CreateClientByImplementation()); + Assert.Same(reflection.Failure, error); + Assert.Equal("metadata", Assert.Single(reflection.Calls)); + } + + [Fact] + public void Default_reflection_uses_actual_properties_including_private_inherited_setters_and_shadowing() + { + var reflection = new SessionReflection(); + var config = new StyleConfig(); + Assert.True(reflection.SetForcePathStyle(config)); + Assert.True(config.ForcePathStyle); + Assert.Throws(() => reflection.SetForcePathStyle(new InheritedStyleConfig())); + using var client = new MockAmazonServiceClient(new BasicAWSCredentials("fixture-id", "fixture-secret"), config); + reflection.SetClientRegion(client, "eu-west-2"); + Assert.Equal("eu-west-2", config.RegionEndpoint?.SystemName); + Assert.Null(((ClientConfig)config).RegionEndpoint); + } + + [Fact] + public void Property_type_errors_and_setter_wrapping_are_not_replaced_by_casts() + { + var reflection = new SessionReflection(); + var wrong = new WrongStyleConfig { ForcePathStyle = "initial" }; + Assert.Throws(() => reflection.SetForcePathStyle(wrong)); + Assert.Equal("initial", wrong.ForcePathStyle); + var throwing = new ThrowingStyleConfig(); + Assert.False(throwing.ForcePathStyle); + Assert.Same(ThrowingStyleConfig.Failure, Assert.Throws(() => throwing.ForcePathStyle = true)); + TargetInvocationException error = Assert.Throws(() => reflection.SetForcePathStyle(throwing)); + Assert.Same(ThrowingStyleConfig.Failure, error.InnerException); + } + + [Fact] + public void Metadata_does_not_inherit_a_private_base_field() + { + var reflection = new SessionReflection(); + Assert.Same(MetadataClient.Metadata, reflection.ExtractServiceMetadata()); + using var derived = new DerivedMetadataClient(); + InvalidOperationException error = Assert.Throws(() => reflection.ExtractServiceMetadata(derived.GetType())); + Assert.Equal($"Invalid service type {typeof(DerivedMetadataClient)}", error.Message); + } + + [Fact] + public void Constructor_filter_preserves_parameter_names_and_single_match_failure() + { + var reflection = new SessionReflection(); + var credentials = new BasicAWSCredentials("fixture-id", "fixture-secret"); + using var renamed = new RenamedParametersClient(credentials, new MockClientConfig()); + using var ambiguous = new AmbiguousConfigClient(credentials, new StyleConfig()); + using var other = new AmbiguousConfigClient(credentials, new MockClientConfig()); + Assert.Throws(() => reflection.CreateClientConfig(renamed.GetType())); + Assert.Throws(() => reflection.CreateClientConfig(ambiguous.GetType())); + Assert.Throws(() => reflection.CreateClientConfig(other.GetType())); + Assert.Same(ThrowingConfig.Failure, Assert.Throws(() => new ThrowingConfigClient(credentials, new ThrowingConfig()))); + TargetInvocationException error = Assert.Throws(() => reflection.CreateClientConfig()); + Assert.Same(ThrowingConfig.Failure, error.InnerException); + } + + [Fact] + public void Client_constructor_exception_retains_reflection_boundary() + { + var session = new Session(new SessionOptions(), new Config(new ConfigOptions()), new RecordingReflection { ExpectedType = typeof(ThrowingClient) }); + Assert.Same(ThrowingClient.Failure, Assert.Throws(() => new ThrowingClient(new BasicAWSCredentials("fixture-id", "fixture-secret"), new MockClientConfig()))); + TargetInvocationException error = Assert.Throws(() => session.CreateClientByImplementation()); + Assert.Same(ThrowingClient.Failure, error.InnerException); + } + + private sealed class RecordingReflection : ISessionReflection + { + public List Calls { get; } = new(); + public StyleConfig Config { get; } = new() { Timeout = TimeSpan.FromSeconds(42) }; + public Exception? Failure { get; set; } + public Type ExpectedType { get; set; } = typeof(MockAmazonServiceClient); + public IServiceMetadata ExtractServiceMetadata() where TClient : AmazonServiceClient => ExtractServiceMetadata(typeof(TClient)); + public IServiceMetadata ExtractServiceMetadata(Type clientType) + { + Assert.Equal(ExpectedType, clientType); + Calls.Add("metadata"); + if (Failure != null) + { + throw Failure; + } + + return new DynamoMetadata(); + } + + public ClientConfig CreateClientConfig() where TClient : AmazonServiceClient => CreateClientConfig(typeof(TClient)); + public ClientConfig CreateClientConfig(Type clientType) + { + Assert.Equal(ExpectedType, clientType); + Calls.Add("config"); + return Config; + } + + public bool SetForcePathStyle(ClientConfig clientConfig, bool value = true) + { + Assert.Same(Config, clientConfig); + Calls.Add("style"); + return false; + } + + public void SetClientRegion(AmazonServiceClient amazonServiceClient, string systemName) => throw new NotSupportedException(); + } + + private sealed class DynamoMetadata : IServiceMetadata + { + public string ServiceId => "DynamoDB"; + public IDictionary OperationNameMapping { get; } = new Dictionary(StringComparer.Ordinal); + } + + private class StyleConfig : MockClientConfig + { + public StyleConfig() + { + ForcePathStyle = false; + RegionEndpoint = null; + } + public bool ForcePathStyle { get; private set; } + public new RegionEndpoint? RegionEndpoint { get; set; } + } + + private sealed class InheritedStyleConfig : StyleConfig + { + public InheritedStyleConfig() : base() { } + } + + private sealed class WrongStyleConfig : MockClientConfig + { + public string? ForcePathStyle { get; set; } + } + + private sealed class ThrowingStyleConfig : MockClientConfig + { + private readonly bool _value; + public ThrowingStyleConfig() => _value = false; + public static InvalidOperationException Failure { get; } = new("fixture setter"); + public bool ForcePathStyle { get => _value; set => throw Failure; } + } + + private class MetadataClient : MockAmazonServiceClient + { + private static readonly IServiceMetadata serviceMetadata = new DynamoMetadata(); + public static IServiceMetadata Metadata => serviceMetadata; + } + + private sealed class DerivedMetadataClient : MetadataClient + { + public DerivedMetadataClient() : base() { } + } + + private sealed class RenamedParametersClient(AWSCredentials credentials, MockClientConfig config) : MockAmazonServiceClient(credentials, config); + + private sealed class AmbiguousConfigClient : MockAmazonServiceClient + { + public AmbiguousConfigClient(AWSCredentials credentials, MockClientConfig clientConfig) : base(credentials, clientConfig) { } + public AmbiguousConfigClient(AWSCredentials credentials, StyleConfig clientConfig) : base(credentials, clientConfig) { } + } + + private sealed class ThrowingConfig : MockClientConfig + { + public static InvalidOperationException Failure { get; } = new("fixture config constructor"); + public ThrowingConfig() => throw Failure; + } + + private sealed class ThrowingConfigClient(AWSCredentials credentials, ThrowingConfig clientConfig) : MockAmazonServiceClient(credentials, clientConfig); + + private sealed class ThrowingClient : MockAmazonServiceClient + { + public static InvalidOperationException Failure { get; } = new("fixture client constructor"); + public ThrowingClient(AWSCredentials credentials, MockClientConfig clientConfig) : base(credentials, clientConfig) => throw Failure; + } +} From f8baa71ac897d8e65e744f418ce65eb2f4c8b3a7 Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Fri, 23 Oct 2026 13:28:14 +0300 Subject: [PATCH 2/6] build: target .NET 10 and .NET 11, build with the .NET 11 SDK .NET 8 and .NET 9 reach end of support on 2026-11-10. - Core: netstandard2.0;net472;net10.0;net11.0 - Extensions: netstandard2.0;net10.0;net11.0 - Tests and sandboxes move to net10.0/net11.0 (net472 kept where present) - global.json selects SDK 11.0.100-rc.2 until .NET 11 GA - Workflows stop installing the .NET 8 and .NET 9 SDKs - The generator is enabled only for net10.0-compatible consumers, so net8/net9 consumers on the netstandard2.0 asset never load it - Drop the System.Text.Json reference that NU1510 flags as prunable - Rename the test helper class Extensions to MockExtensions (CA1724 on net11.0) --- .github/workflows/aws-sdk-canary.yml | 2 -- .github/workflows/ci-cd.yml | 4 ---- .github/workflows/publish-nuget.yml | 2 -- build/LocalStack.Build/LocalStack.Build.csproj | 2 +- global.json | 4 ++-- .../LocalStack.Client.Extensions.csproj | 4 ++-- src/LocalStack.Client/LocalStack.Client.csproj | 2 +- .../buildTransitive/LocalStack.Client.targets | 2 +- tests/Directory.Build.targets | 2 +- .../LocalStack.Client.Extensions.Tests.csproj | 2 +- .../LocalStack.Client.Functional.Tests.csproj | 3 +-- .../LocalStack.Client.Integration.Tests.csproj | 2 +- tests/LocalStack.Client.Tests/LocalStack.Client.Tests.csproj | 2 +- .../LocalStack.Tests.Common/LocalStack.Tests.Common.csproj | 2 +- .../Mocks/{Extensions.cs => MockExtensions.cs} | 2 +- .../LocalStack.Client.Sandbox.DependencyInjection.csproj | 2 +- .../LocalStack.Client.Sandbox.WithGenericHost.csproj | 2 +- .../LocalStack.Client.Sandbox.csproj | 2 +- .../LocalStack.Container/LocalStack.Container.csproj | 2 +- 19 files changed, 18 insertions(+), 27 deletions(-) rename tests/common/LocalStack.Tests.Common/Mocks/{Extensions.cs => MockExtensions.cs} (96%) diff --git a/.github/workflows/aws-sdk-canary.yml b/.github/workflows/aws-sdk-canary.yml index 59e19dd..3d7f89d 100644 --- a/.github/workflows/aws-sdk-canary.yml +++ b/.github/workflows/aws-sdk-canary.yml @@ -37,8 +37,6 @@ jobs: uses: actions/setup-dotnet@v6 with: dotnet-version: | - 8.0.x - 9.0.x 10.0.x global-json-file: global.json diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index b6db276..b6fa27a 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -68,8 +68,6 @@ jobs: uses: actions/setup-dotnet@v6 with: dotnet-version: | - 8.0.x - 9.0.x 10.0.x global-json-file: global.json @@ -169,8 +167,6 @@ jobs: uses: actions/setup-dotnet@v6 with: dotnet-version: | - 8.0.x - 9.0.x 10.0.x global-json-file: global.json diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index 1c3277b..0c2e58e 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -51,8 +51,6 @@ jobs: uses: actions/setup-dotnet@v6 with: dotnet-version: | - 8.0.x - 9.0.x 10.0.x global-json-file: global.json diff --git a/build/LocalStack.Build/LocalStack.Build.csproj b/build/LocalStack.Build/LocalStack.Build.csproj index 00369f3..3f826ad 100644 --- a/build/LocalStack.Build/LocalStack.Build.csproj +++ b/build/LocalStack.Build/LocalStack.Build.csproj @@ -1,7 +1,7 @@ Exe - net9.0 + net10.0 $(MSBuildProjectDirectory) latest diff --git a/global.json b/global.json index 0ed0a06..735c8fb 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "10.0.402", + "version": "11.0.100-rc.2.26504.105", "rollForward": "latestFeature", - "allowPrerelease": false + "allowPrerelease": true } } diff --git a/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj b/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj index 74a3876..48c9905 100644 --- a/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj +++ b/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj @@ -1,7 +1,7 @@  - netstandard2.0;net8.0;net9.0;net10.0 + netstandard2.0;net10.0;net11.0 LocalStack.Client.Extensions LocalStack.Client.Extensions $(PackageExtensionVersion) @@ -36,7 +36,7 @@ - + diff --git a/src/LocalStack.Client/LocalStack.Client.csproj b/src/LocalStack.Client/LocalStack.Client.csproj index a26f5d6..7e0671a 100644 --- a/src/LocalStack.Client/LocalStack.Client.csproj +++ b/src/LocalStack.Client/LocalStack.Client.csproj @@ -1,7 +1,7 @@  - netstandard2.0;net472;net8.0;net9.0;net10.0 + netstandard2.0;net472;net10.0;net11.0 LocalStack.Client LocalStack.Client diff --git a/src/LocalStack.Client/buildTransitive/LocalStack.Client.targets b/src/LocalStack.Client/buildTransitive/LocalStack.Client.targets index 91f8f27..605a42a 100644 --- a/src/LocalStack.Client/buildTransitive/LocalStack.Client.targets +++ b/src/LocalStack.Client/buildTransitive/LocalStack.Client.targets @@ -1,5 +1,5 @@ - + diff --git a/tests/Directory.Build.targets b/tests/Directory.Build.targets index 1d11f3a..18fb5a5 100644 --- a/tests/Directory.Build.targets +++ b/tests/Directory.Build.targets @@ -1,5 +1,5 @@ - + diff --git a/tests/LocalStack.Client.Extensions.Tests/LocalStack.Client.Extensions.Tests.csproj b/tests/LocalStack.Client.Extensions.Tests/LocalStack.Client.Extensions.Tests.csproj index 5c058a9..a3138c5 100644 --- a/tests/LocalStack.Client.Extensions.Tests/LocalStack.Client.Extensions.Tests.csproj +++ b/tests/LocalStack.Client.Extensions.Tests/LocalStack.Client.Extensions.Tests.csproj @@ -1,7 +1,7 @@  - net8.0;net9.0;net10.0 + net10.0;net11.0 $(NoWarn);CA1707;MA0006 diff --git a/tests/LocalStack.Client.Functional.Tests/LocalStack.Client.Functional.Tests.csproj b/tests/LocalStack.Client.Functional.Tests/LocalStack.Client.Functional.Tests.csproj index 414845b..5d745fe 100644 --- a/tests/LocalStack.Client.Functional.Tests/LocalStack.Client.Functional.Tests.csproj +++ b/tests/LocalStack.Client.Functional.Tests/LocalStack.Client.Functional.Tests.csproj @@ -1,7 +1,7 @@  - net8.0;net9.0;net10.0 + net10.0;net11.0 latest $(NoWarn);CA1707;MA0006;MA0004;CA1711;CA2007;MA0132;CA1848;CA2254;CA1873;S4144;CA1515 @@ -38,7 +38,6 @@ - diff --git a/tests/LocalStack.Client.Integration.Tests/LocalStack.Client.Integration.Tests.csproj b/tests/LocalStack.Client.Integration.Tests/LocalStack.Client.Integration.Tests.csproj index 87083d2..88d3f86 100644 --- a/tests/LocalStack.Client.Integration.Tests/LocalStack.Client.Integration.Tests.csproj +++ b/tests/LocalStack.Client.Integration.Tests/LocalStack.Client.Integration.Tests.csproj @@ -1,7 +1,7 @@  - net472;net8.0;net9.0;net10.0 + net472;net10.0;net11.0 $(NoWarn);CA1707;MA0006;CA1510 diff --git a/tests/LocalStack.Client.Tests/LocalStack.Client.Tests.csproj b/tests/LocalStack.Client.Tests/LocalStack.Client.Tests.csproj index b6f7307..8cd0888 100644 --- a/tests/LocalStack.Client.Tests/LocalStack.Client.Tests.csproj +++ b/tests/LocalStack.Client.Tests/LocalStack.Client.Tests.csproj @@ -1,7 +1,7 @@  - net472;net8.0;net9.0;net10.0 + net472;net10.0;net11.0 $(NoWarn);CA1707;MA0006 diff --git a/tests/common/LocalStack.Tests.Common/LocalStack.Tests.Common.csproj b/tests/common/LocalStack.Tests.Common/LocalStack.Tests.Common.csproj index 5f1c307..fd34a37 100644 --- a/tests/common/LocalStack.Tests.Common/LocalStack.Tests.Common.csproj +++ b/tests/common/LocalStack.Tests.Common/LocalStack.Tests.Common.csproj @@ -1,7 +1,7 @@ - net472;net8.0;net9.0;net10.0 + net472;net10.0;net11.0 $(NoWarn);CA1707;MA0006;CA1510 diff --git a/tests/common/LocalStack.Tests.Common/Mocks/Extensions.cs b/tests/common/LocalStack.Tests.Common/Mocks/MockExtensions.cs similarity index 96% rename from tests/common/LocalStack.Tests.Common/Mocks/Extensions.cs rename to tests/common/LocalStack.Tests.Common/Mocks/MockExtensions.cs index 7081efe..c8f69d0 100644 --- a/tests/common/LocalStack.Tests.Common/Mocks/Extensions.cs +++ b/tests/common/LocalStack.Tests.Common/Mocks/MockExtensions.cs @@ -1,6 +1,6 @@ namespace LocalStack.Tests.Common.Mocks; -public static class Extensions +public static class MockExtensions { public static (string awsAccessKeyId, string awsAccessKey, string awsSessionToken, string regionName) SetupDefault( this Mock mock, string awsAccessKeyId = "AwsAccessKeyId", string awsAccessKey = "AwsAccessKey", string awsSessionToken = "AwsSessionToken", diff --git a/tests/sandboxes/LocalStack.Client.Sandbox.DependencyInjection/LocalStack.Client.Sandbox.DependencyInjection.csproj b/tests/sandboxes/LocalStack.Client.Sandbox.DependencyInjection/LocalStack.Client.Sandbox.DependencyInjection.csproj index 53b3f6a..14fa35d 100644 --- a/tests/sandboxes/LocalStack.Client.Sandbox.DependencyInjection/LocalStack.Client.Sandbox.DependencyInjection.csproj +++ b/tests/sandboxes/LocalStack.Client.Sandbox.DependencyInjection/LocalStack.Client.Sandbox.DependencyInjection.csproj @@ -2,7 +2,7 @@ Exe - net8.0;net9.0;net10.0 + net10.0;net11.0 $(NoWarn);CS0246;S125;CA1305;CA1031;CA1303;CA1848;MA0004;CA2007 diff --git a/tests/sandboxes/LocalStack.Client.Sandbox.WithGenericHost/LocalStack.Client.Sandbox.WithGenericHost.csproj b/tests/sandboxes/LocalStack.Client.Sandbox.WithGenericHost/LocalStack.Client.Sandbox.WithGenericHost.csproj index 237b944..778435a 100644 --- a/tests/sandboxes/LocalStack.Client.Sandbox.WithGenericHost/LocalStack.Client.Sandbox.WithGenericHost.csproj +++ b/tests/sandboxes/LocalStack.Client.Sandbox.WithGenericHost/LocalStack.Client.Sandbox.WithGenericHost.csproj @@ -2,7 +2,7 @@ Exe - net8.0;net9.0;net10.0 + net10.0;net11.0 latest $(NoWarn);CS0246;S125;CA1305;CA1031;CA1303;CA1848;CA1873;MA0004;CA2007 diff --git a/tests/sandboxes/LocalStack.Client.Sandbox/LocalStack.Client.Sandbox.csproj b/tests/sandboxes/LocalStack.Client.Sandbox/LocalStack.Client.Sandbox.csproj index 6a7003e..edfdd3f 100644 --- a/tests/sandboxes/LocalStack.Client.Sandbox/LocalStack.Client.Sandbox.csproj +++ b/tests/sandboxes/LocalStack.Client.Sandbox/LocalStack.Client.Sandbox.csproj @@ -2,7 +2,7 @@ Exe - net472;net8.0;net9.0;net10.0 + net472;net10.0;net11.0 $(NoWarn);CS0246;S125;CA1305;CA1031;CA1303;CA1848;MA0004;CA2007 diff --git a/tests/sandboxes/LocalStack.Container/LocalStack.Container.csproj b/tests/sandboxes/LocalStack.Container/LocalStack.Container.csproj index 8e92cd2..9373e81 100644 --- a/tests/sandboxes/LocalStack.Container/LocalStack.Container.csproj +++ b/tests/sandboxes/LocalStack.Container/LocalStack.Container.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 latest $(NoWarn);CS0246;S125;CA1305;CA1031;CA1303;CA1848;MA0004;CA2007 From 7dc6a51e8beff1cce53e926a7519dfaf947efec7 Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Fri, 23 Oct 2026 14:22:18 +0300 Subject: [PATCH 3/6] ci: run .NET Framework tests on Windows only The .NET 11 SDK's test platform no longer runs .NET Framework test assemblies through Mono ("Running .NET Framework tests is supported on Windows only"), so the Linux and macOS jobs failed on the net472 runs. Skip net472 with a warning off Windows; the Windows job still runs it. Drop the macOS Mono install step. --- .github/workflows/ci-cd.yml | 12 ------------ build/LocalStack.Build/CakeTasks/TestTask.cs | 9 ++++----- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index b6fa27a..acf073d 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -71,18 +71,6 @@ jobs: 10.0.x global-json-file: global.json - # The macOS runner image no longer ships Mono, and VSTest needs the real 'mono' host - # on the PATH to execute the net472 test assemblies. - - name: "Install Mono (macOS)" - if: runner.os == 'macOS' - run: | - if command -v mono >/dev/null 2>&1; then - echo "✅ Mono already present: $(mono --version | head -1)" - else - brew install mono - echo "✅ Installed: $(mono --version | head -1)" - fi - - name: "Cache NuGet packages" uses: actions/cache@v6 with: diff --git a/build/LocalStack.Build/CakeTasks/TestTask.cs b/build/LocalStack.Build/CakeTasks/TestTask.cs index 11adf24..be8e669 100644 --- a/build/LocalStack.Build/CakeTasks/TestTask.cs +++ b/build/LocalStack.Build/CakeTasks/TestTask.cs @@ -47,13 +47,12 @@ public override void Run(BuildContext context) settings.Filter = null; } - // .NET Framework testing on non-Windows platforms - // - Modern .NET includes built-in Mono runtime - // - Test platform still requires external Mono installation on Linux + // The .NET 11 SDK's test platform runs .NET Framework tests on Windows only; Mono is no longer supported. if (targetFramework == "net472" && !context.IsRunningOnWindows()) { - string platform = context.IsRunningOnLinux() ? "Linux (with external Mono)" : "macOS (built-in Mono)"; - ConsoleHelper.WriteInfo($"Running .NET Framework tests on {platform}"); + ConsoleHelper.WriteWarning($"Skipping {targetFramework} tests for {testProj.AssemblyName}: .NET Framework tests run on Windows only"); + + continue; } string testFilePrefix = targetFramework.Replace('.', '-'); From a8affd77a806b56fcb21a19ba7cdab3e1562f6ca Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Fri, 23 Oct 2026 16:28:30 +0300 Subject: [PATCH 4/6] build: ignore the dropped net8.0/net9.0 assets in Core package validation Package validation compared the 2.0.0 net8.0/net9.0 assets with the netstandard2.0 asset that .NET 8/9 apps now resolve, and reported that the AwsService enum no longer implements ISpanFormattable there. On a .NET 8+ runtime every enum still implements it through System.Enum, so this is an artifact of dropping those target frameworks, which PackageValidationBaselineFrameworkToIgnore declares explicitly. --- src/LocalStack.Client/LocalStack.Client.csproj | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/LocalStack.Client/LocalStack.Client.csproj b/src/LocalStack.Client/LocalStack.Client.csproj index 7e0671a..278964c 100644 --- a/src/LocalStack.Client/LocalStack.Client.csproj +++ b/src/LocalStack.Client/LocalStack.Client.csproj @@ -21,6 +21,11 @@ $(NoWarn);MA0006;CA1510 + + + + + true From 5ca639e81420d87594d85772368444d964712e03 Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Fri, 23 Oct 2026 22:24:40 +0300 Subject: [PATCH 5/6] test: name the compatibility and generator tests by the Should convention --- .../OptionsBindingCompatibilityTests.cs | 16 ++++++++-------- .../GeneratorTests.cs | 18 +++++++++--------- .../SessionTests/SessionCompatibilityTests.cs | 16 ++++++++-------- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/tests/LocalStack.Client.Extensions.Tests/OptionsBindingCompatibilityTests.cs b/tests/LocalStack.Client.Extensions.Tests/OptionsBindingCompatibilityTests.cs index 5a74745..2a37c9f 100644 --- a/tests/LocalStack.Client.Extensions.Tests/OptionsBindingCompatibilityTests.cs +++ b/tests/LocalStack.Client.Extensions.Tests/OptionsBindingCompatibilityTests.cs @@ -3,7 +3,7 @@ namespace LocalStack.Client.Extensions.Tests; public class OptionsBindingCompatibilityTests { [Fact] - public void Binding_is_lazy_and_preserves_independent_nested_options() + public void AddLocalStack_Should_Bind_Options_Lazily_And_Keep_Nested_Options_Independent() { IConfigurationRoot configuration = Configuration(new Dictionary(StringComparer.Ordinal) { @@ -58,7 +58,7 @@ public void Binding_is_lazy_and_preserves_independent_nested_options() [InlineData("#10", 16)] [InlineData(" +17 ", 17)] [InlineData("-17", -17)] - public void Integers_use_the_invariant_converter(string text, int expected) + public void AddLocalStack_Should_Convert_Integers_With_The_Invariant_Converter(string text, int expected) { CultureInfo previous = CultureInfo.CurrentCulture; try @@ -81,7 +81,7 @@ public void Integers_use_the_invariant_converter(string text, int expected) [InlineData("LocalStack:Config:EdgePort", "1,2", typeof(int))] [InlineData("LocalStack:Config:UseSsl", "", typeof(bool))] [InlineData("LocalStack:Config:UseSsl", "yes", typeof(bool))] - public void Conversion_errors_retain_path_type_and_converter_exception(string path, string text, Type type) + public void AddLocalStack_Should_Report_Path_Type_And_Converter_Exception_When_Conversion_Fails(string path, string text, Type type) { var services = new ServiceCollection(); services.AddLocalStack(Configuration(new Dictionary(StringComparer.Ordinal) { [path] = text })); @@ -98,7 +98,7 @@ public void Conversion_errors_retain_path_type_and_converter_exception(string pa } [Fact] - public void Missing_null_and_child_only_scalars_preserve_prior_values_but_empty_strings_bind() + public void AddLocalStack_Should_Keep_Prior_Values_For_Missing_Null_Or_Child_Only_Scalars_And_Bind_Empty_Strings() { var services = new ServiceCollection(); services.Configure(options => SetPrivate(options, nameof(SessionOptions.RegionName), "earlier")); @@ -121,7 +121,7 @@ public void Missing_null_and_child_only_scalars_preserve_prior_values_but_empty_ } [Fact] - public void Configure_postconfigure_validation_and_monitor_keep_framework_order() + public void AddLocalStack_Should_Keep_Framework_Order_For_Configure_PostConfigure_Validation_And_Monitor() { var observed = new List(); var services = new ServiceCollection(); @@ -140,7 +140,7 @@ public void Configure_postconfigure_validation_and_monitor_keep_framework_order( } [Fact] - public void Missing_sections_retain_all_defaults() + public void AddLocalStack_Should_Keep_All_Defaults_When_Sections_Are_Missing() { var services = new ServiceCollection(); services.AddLocalStack(Configuration(new Dictionary(StringComparer.Ordinal))); @@ -162,7 +162,7 @@ public void Missing_sections_retain_all_defaults() [Theory] [InlineData(false)] [InlineData(true)] - public void Previously_null_nested_options_are_created_only_when_the_section_has_children(bool hasChildren) + public void AddLocalStack_Should_Create_Null_Nested_Options_Only_When_The_Section_Has_Children(bool hasChildren) { IConfigurationRoot configuration = Configuration(hasChildren ? new Dictionary(StringComparer.Ordinal) { ["LocalStack:Session:RegionName"] = "bound", ["LocalStack:Config:UseSsl"] = "true" } @@ -201,7 +201,7 @@ public void Previously_null_nested_options_are_created_only_when_the_section_has } [Fact] - public void A_failed_new_nested_binding_does_not_publish_the_partially_bound_object() + public void AddLocalStack_Should_Not_Publish_A_Partially_Bound_Nested_Object_When_Binding_Fails() { IConfigurationRoot configuration = Configuration(new Dictionary(StringComparer.Ordinal) { diff --git a/tests/LocalStack.Client.Generators.Tests/GeneratorTests.cs b/tests/LocalStack.Client.Generators.Tests/GeneratorTests.cs index eafc082..b348331 100644 --- a/tests/LocalStack.Client.Generators.Tests/GeneratorTests.cs +++ b/tests/LocalStack.Client.Generators.Tests/GeneratorTests.cs @@ -16,7 +16,7 @@ namespace LocalStack.Client.Generators.Tests; public class GeneratorTests { [Fact] - public void ReferencedSdkIsDiscoveredWithoutRegistrationSyntax() + public void Generator_Should_Discover_Referenced_Sdk_Clients_Without_Registration_Code() { (GeneratorDriverRunResult result, Compilation output) = Generate("internal class Consumer { }"); Assert.Empty(result.Diagnostics); @@ -31,7 +31,7 @@ public void ReferencedSdkIsDiscoveredWithoutRegistrationSyntax() } [Fact] - public void ConsumerGlobalImportsCannotChangeGeneratedTypeIdentity() + public void Generator_Should_Keep_Type_Identity_When_Consumer_Declares_Global_Usings() { (_, Compilation output) = Generate("global using Collision; namespace Collision { internal class Type { } }"); AssertNoCompilationErrors(output); @@ -52,7 +52,7 @@ public DynamicallyAccessedMembersAttribute(DynamicallyAccessedMemberTypes types) } internal enum DynamicallyAccessedMemberTypes { NonPublicFields, PublicConstructors, PublicProperties } """)] - public void ConsumerGlobalDelegateAndAttributeNamesCannotBreakGeneratedCompilation(string declarations) + public void Generator_Should_Compile_When_Consumer_Declares_Global_Delegate_And_Attribute_Names(string declarations) { (_, Compilation output) = Generate("global using Collision; namespace Collision { " + declarations + " }"); AssertNoCompilationErrors(output); @@ -61,7 +61,7 @@ public void ConsumerGlobalDelegateAndAttributeNamesCannotBreakGeneratedCompilati } [Fact] - public void PrivateClosedGenericClientsRemainLazyAndUseTheirOwnMetadataAndConfig() + public void Generator_Should_Register_Private_Closed_Generic_Clients_Lazily_With_Their_Own_Metadata_And_Config() { (_, Compilation output) = Generate(PrivateGenericFixture); AssertNoCompilationErrors(output); @@ -91,7 +91,7 @@ public void PrivateClosedGenericClientsRemainLazyAndUseTheirOwnMetadataAndConfig } [Fact] - public void MultipleInitializersDoNotEvaluateClientOrConfigOperations() + public void Generator_Should_Not_Evaluate_Client_Or_Config_Operations_During_Registration() { (_, Compilation output) = Generate(PrivateGenericFixture); Assembly assembly = Load(output); @@ -105,7 +105,7 @@ public void MultipleInitializersDoNotEvaluateClientOrConfigOperations() } [Fact] - public void ReferencedConfigsWithTheSameFullNameKeepActualAssemblyIdentity() + public void Generator_Should_Keep_Assembly_Identity_For_Referenced_Configs_With_The_Same_Full_Name() { const string configSource = "namespace Shared { public class Config : Amazon.S3.AmazonS3Config { } }"; Compilation first = CreateCompilation(configSource); @@ -146,7 +146,7 @@ public static class Consumer { } [Fact] - public void InaccessibleReferencedConfigsAreDiscoveredWithoutConsumerTypeSyntax() + public void Generator_Should_Discover_Inaccessible_Referenced_Configs_Without_Consumer_Syntax() { Compilation library = CreateCompilation(""" public static class Factory { @@ -169,7 +169,7 @@ private sealed class PrivateConfig : Amazon.S3.AmazonS3Config { } } [Fact] - public void ReferencedClosedSignaturesAreRegisteredWithoutConsumerGenericSyntaxAndRemainLazy() + public void Generator_Should_Register_Closed_Signature_Types_Lazily_Without_Consumer_Generic_Syntax() { Compilation library = CreateCompilation(ReferencedSignatureFixture); Assembly libraryAssembly = Load(library); @@ -218,7 +218,7 @@ public void ReferencedClosedSignaturesAreRegisteredWithoutConsumerGenericSyntaxA } [Fact] - public void UnconstrainedTypeConfigCreationDoesNotRequireAClientBaseClassOrMetadata() + public void CreateClientConfig_Should_Accept_Types_Without_A_Client_Base_Class_Or_Metadata() { (_, Compilation output) = Generate(""" public class Ordinary { diff --git a/tests/LocalStack.Client.Tests/SessionTests/SessionCompatibilityTests.cs b/tests/LocalStack.Client.Tests/SessionTests/SessionCompatibilityTests.cs index accb9da..af4ba73 100644 --- a/tests/LocalStack.Client.Tests/SessionTests/SessionCompatibilityTests.cs +++ b/tests/LocalStack.Client.Tests/SessionTests/SessionCompatibilityTests.cs @@ -8,7 +8,7 @@ public class SessionCompatibilityTests [Theory] [InlineData(true)] [InlineData(false)] - public void Injected_reflection_controls_metadata_config_style_and_credentials(bool byInterface) + public void CreateClient_Should_Use_Injected_Reflection_For_Metadata_Config_Style_And_Credentials(bool byInterface) { var reflection = new RecordingReflection(); var session = new Session(new SessionOptions("fixture-id", "fixture-secret", "fixture-token", "eu-west-2"), @@ -32,7 +32,7 @@ public void Injected_reflection_controls_metadata_config_style_and_credentials(b } [Fact] - public void Region_validation_precedes_injected_metadata_for_implementation_calls() + public void CreateClientByImplementation_Should_Validate_Region_Before_Calling_Injected_Reflection() { var reflection = new RecordingReflection(); var session = new Session(new SessionOptions(regionName: ""), new Config(new ConfigOptions()), reflection); @@ -44,7 +44,7 @@ public void Region_validation_precedes_injected_metadata_for_implementation_call } [Fact] - public void Injected_failure_identity_and_no_later_calls_are_preserved() + public void CreateClientByImplementation_Should_Rethrow_Injected_Failure_And_Make_No_Later_Calls() { var reflection = new RecordingReflection { Failure = new InvalidOperationException("fixture metadata failure") }; var session = new Session(new SessionOptions(), new Config(new ConfigOptions()), reflection); @@ -54,7 +54,7 @@ public void Injected_failure_identity_and_no_later_calls_are_preserved() } [Fact] - public void Default_reflection_uses_actual_properties_including_private_inherited_setters_and_shadowing() + public void SessionReflection_Should_Use_Actual_Properties_Including_Private_Inherited_Setters_And_Shadowing() { var reflection = new SessionReflection(); var config = new StyleConfig(); @@ -68,7 +68,7 @@ public void Default_reflection_uses_actual_properties_including_private_inherite } [Fact] - public void Property_type_errors_and_setter_wrapping_are_not_replaced_by_casts() + public void SetForcePathStyle_Should_Keep_Property_Type_Errors_And_Setter_Exception_Wrapping() { var reflection = new SessionReflection(); var wrong = new WrongStyleConfig { ForcePathStyle = "initial" }; @@ -82,7 +82,7 @@ public void Property_type_errors_and_setter_wrapping_are_not_replaced_by_casts() } [Fact] - public void Metadata_does_not_inherit_a_private_base_field() + public void ExtractServiceMetadata_Should_Not_Read_A_Private_Base_Class_Field() { var reflection = new SessionReflection(); Assert.Same(MetadataClient.Metadata, reflection.ExtractServiceMetadata()); @@ -92,7 +92,7 @@ public void Metadata_does_not_inherit_a_private_base_field() } [Fact] - public void Constructor_filter_preserves_parameter_names_and_single_match_failure() + public void CreateClientConfig_Should_Match_Constructor_Parameter_Names_And_Fail_On_Zero_Or_Many_Matches() { var reflection = new SessionReflection(); var credentials = new BasicAWSCredentials("fixture-id", "fixture-secret"); @@ -108,7 +108,7 @@ public void Constructor_filter_preserves_parameter_names_and_single_match_failur } [Fact] - public void Client_constructor_exception_retains_reflection_boundary() + public void CreateClientByImplementation_Should_Wrap_Client_Constructor_Exceptions_In_TargetInvocationException() { var session = new Session(new SessionOptions(), new Config(new ConfigOptions()), new RecordingReflection { ExpectedType = typeof(ThrowingClient) }); Assert.Same(ThrowingClient.Failure, Assert.Throws(() => new ThrowingClient(new BasicAWSCredentials("fixture-id", "fixture-secret"), new MockClientConfig()))); From 25aa8ff050b803b10d639c5a10556ab821bcf4f2 Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Fri, 23 Oct 2026 22:24:40 +0300 Subject: [PATCH 6/6] build: define target frameworks and the modern-target gate once Directory.Build.props now owns the framework sets and the modern-target gate instead of repeating net10.0/net11.0 literals across projects: - LocalStackModernMinimumTargetFramework / LocalStackModernTargetFrameworks (the targets that get Native AOT, the generator and the reflection-free paths), plus the netstandard2.0 and net472 targets and the per-package lists built from them - IsLocalStackModernTarget / IsLocalStackNetFrameworkTarget (true/false) for conditions - the package-validation baseline version and the frameworks it drops The packaged buildTransitive gate reads LocalStackClientGeneratorMinimumTargetFramework from a props file that pack generates from LocalStackModernMinimumTargetFramework, so adding a .NET version is a one-line change. Cake now asks MSBuild for evaluated target frameworks instead of parsing project text. tests/Directory.Build.props holds the settings every test project shared. --- Directory.Build.props | 25 +++++++++++ build/LocalStack.Build/BuildContext.cs | 44 ++++++++++++++----- .../LocalStack.Build/LocalStack.Build.csproj | 2 +- .../LocalStack.Client.Extensions.csproj | 10 ++--- .../LocalStack.Client.csproj | 29 +++++++++--- .../buildTransitive/LocalStack.Client.targets | 4 +- tests/Directory.Build.props | 10 +++++ tests/Directory.Build.targets | 2 +- .../LocalStack.Client.Extensions.Tests.csproj | 3 +- .../LocalStack.Client.Functional.Tests.csproj | 4 +- .../LocalStack.Client.Generators.Tests.csproj | 2 +- ...LocalStack.Client.Integration.Tests.csproj | 6 +-- .../LocalStack.Client.Tests.csproj | 5 +-- .../LocalStack.Tests.Common.csproj | 4 +- ....Client.Sandbox.DependencyInjection.csproj | 2 +- ...tack.Client.Sandbox.WithGenericHost.csproj | 2 +- .../LocalStack.Client.Sandbox.csproj | 2 +- .../LocalStack.Container.csproj | 2 +- 18 files changed, 117 insertions(+), 41 deletions(-) create mode 100644 tests/Directory.Build.props diff --git a/Directory.Build.props b/Directory.Build.props index c28594e..eb3db50 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -41,6 +41,31 @@ true + + net10.0 + $(LocalStackModernMinimumTargetFramework);net11.0 + netstandard2.0 + net472 + + $(LocalStackNetStandardTargetFramework);$(LocalStackNetFrameworkTargetFramework);$(LocalStackModernTargetFrameworks) + $(LocalStackNetStandardTargetFramework);$(LocalStackModernTargetFrameworks) + + $(LocalStackNetFrameworkTargetFramework);$(LocalStackModernTargetFrameworks) + + + 2.0.0 + + net8.0;net9.0 + + + + + + false + true + false + true diff --git a/build/LocalStack.Build/BuildContext.cs b/build/LocalStack.Build/BuildContext.cs index e934952..35062f9 100644 --- a/build/LocalStack.Build/BuildContext.cs +++ b/build/LocalStack.Build/BuildContext.cs @@ -385,21 +385,45 @@ private static string ToPreReleaseIdentifier(string identifier) return NuGetVersion.TryParse($"0.0.0-{sanitised}", out NuGetVersion? _) ? sanitised : $"g{sanitised}"; } - private string[] GetProjectTargetFrameworks(string csprojPath) + // Asks MSBuild for the evaluated frameworks, so lists built from Directory.Build.props properties resolve. + private static string[] GetProjectTargetFrameworks(string csprojPath) { - FilePath file = this.File(csprojPath); - string project = File.ReadAllText(file.FullPath, Encoding.UTF8); + string dotnetHost = System.Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? + throw new InvalidOperationException("DOTNET_HOST_PATH is not set; run the build through the dotnet CLI."); - bool multipleFrameworks = project.Contains(""); - string startElement = multipleFrameworks ? "" : ""; - string endElement = multipleFrameworks ? "" : ""; + var startInfo = new System.Diagnostics.ProcessStartInfo(dotnetHost) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add("msbuild"); + startInfo.ArgumentList.Add(csprojPath); + startInfo.ArgumentList.Add("-getProperty:TargetFrameworks"); + startInfo.ArgumentList.Add("-getProperty:TargetFramework"); + + using System.Diagnostics.Process process = System.Diagnostics.Process.Start(startInfo) ?? + throw new InvalidOperationException($"Could not start '{dotnetHost} msbuild'."); + string output = process.StandardOutput.ReadToEnd(); + string error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException($"'dotnet msbuild {csprojPath}' exited with code {process.ExitCode}: {output}{error}"); + } - int startIndex = project.IndexOf(startElement, StringComparison.Ordinal) + startElement.Length; - int endIndex = project.IndexOf(endElement, startIndex, StringComparison.Ordinal); + using var document = System.Text.Json.JsonDocument.Parse(output); + System.Text.Json.JsonElement properties = document.RootElement.GetProperty("Properties"); + string? frameworks = properties.GetProperty("TargetFrameworks").GetString(); - string targetFrameworks = project[startIndex..endIndex]; + if (string.IsNullOrWhiteSpace(frameworks)) + { + frameworks = properties.GetProperty("TargetFramework").GetString(); + } - return targetFrameworks.Split(';'); + return (frameworks ?? string.Empty).Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); } private string GetAssemblyName(string csprojPath) diff --git a/build/LocalStack.Build/LocalStack.Build.csproj b/build/LocalStack.Build/LocalStack.Build.csproj index 3f826ad..7dbd321 100644 --- a/build/LocalStack.Build/LocalStack.Build.csproj +++ b/build/LocalStack.Build/LocalStack.Build.csproj @@ -1,7 +1,7 @@ Exe - net10.0 + $(LocalStackModernMinimumTargetFramework) $(MSBuildProjectDirectory) latest diff --git a/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj b/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj index 48c9905..aadc28b 100644 --- a/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj +++ b/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj @@ -1,7 +1,7 @@  - netstandard2.0;net10.0;net11.0 + $(LocalStackExtensionsTargetFrameworks) LocalStack.Client.Extensions LocalStack.Client.Extensions $(PackageExtensionVersion) @@ -14,7 +14,7 @@ LICENSE.txt README.md true - 2.0.0 + $(LocalStackPackageValidationBaselineVersion) true true $(NoWarn);CA1510 @@ -35,14 +35,12 @@ - - + - - + diff --git a/src/LocalStack.Client/LocalStack.Client.csproj b/src/LocalStack.Client/LocalStack.Client.csproj index 278964c..1b5f40d 100644 --- a/src/LocalStack.Client/LocalStack.Client.csproj +++ b/src/LocalStack.Client/LocalStack.Client.csproj @@ -1,7 +1,7 @@  - netstandard2.0;net472;net10.0;net11.0 + $(LocalStackClientTargetFrameworks) LocalStack.Client LocalStack.Client @@ -14,7 +14,7 @@ LICENSE.txt README.md true - 2.0.0 + $(LocalStackPackageValidationBaselineVersion) true true @@ -22,8 +22,7 @@ - - + @@ -39,6 +38,26 @@ + + + + $(IntermediateOutputPath)buildTransitive/LocalStack.Client.props + + + + + + + + + + + + + + + @@ -60,7 +79,7 @@ - + NET472 diff --git a/src/LocalStack.Client/buildTransitive/LocalStack.Client.targets b/src/LocalStack.Client/buildTransitive/LocalStack.Client.targets index 605a42a..73f112e 100644 --- a/src/LocalStack.Client/buildTransitive/LocalStack.Client.targets +++ b/src/LocalStack.Client/buildTransitive/LocalStack.Client.targets @@ -1,5 +1,7 @@ - + + diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 0000000..69da800 --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,10 @@ + + + + + + + $(NoWarn);CA1707;MA0006 + + diff --git a/tests/Directory.Build.targets b/tests/Directory.Build.targets index 18fb5a5..496915e 100644 --- a/tests/Directory.Build.targets +++ b/tests/Directory.Build.targets @@ -1,5 +1,5 @@ - + diff --git a/tests/LocalStack.Client.Extensions.Tests/LocalStack.Client.Extensions.Tests.csproj b/tests/LocalStack.Client.Extensions.Tests/LocalStack.Client.Extensions.Tests.csproj index a3138c5..90632bd 100644 --- a/tests/LocalStack.Client.Extensions.Tests/LocalStack.Client.Extensions.Tests.csproj +++ b/tests/LocalStack.Client.Extensions.Tests/LocalStack.Client.Extensions.Tests.csproj @@ -1,8 +1,7 @@  - net10.0;net11.0 - $(NoWarn);CA1707;MA0006 + $(LocalStackModernTargetFrameworks) diff --git a/tests/LocalStack.Client.Functional.Tests/LocalStack.Client.Functional.Tests.csproj b/tests/LocalStack.Client.Functional.Tests/LocalStack.Client.Functional.Tests.csproj index 5d745fe..09accf7 100644 --- a/tests/LocalStack.Client.Functional.Tests/LocalStack.Client.Functional.Tests.csproj +++ b/tests/LocalStack.Client.Functional.Tests/LocalStack.Client.Functional.Tests.csproj @@ -1,9 +1,9 @@  - net10.0;net11.0 + $(LocalStackModernTargetFrameworks) latest - $(NoWarn);CA1707;MA0006;MA0004;CA1711;CA2007;MA0132;CA1848;CA2254;CA1873;S4144;CA1515 + $(NoWarn);MA0004;CA1711;CA2007;MA0132;CA1848;CA2254;CA1873;S4144;CA1515 diff --git a/tests/LocalStack.Client.Generators.Tests/LocalStack.Client.Generators.Tests.csproj b/tests/LocalStack.Client.Generators.Tests/LocalStack.Client.Generators.Tests.csproj index 8c86da1..1fa447c 100644 --- a/tests/LocalStack.Client.Generators.Tests/LocalStack.Client.Generators.Tests.csproj +++ b/tests/LocalStack.Client.Generators.Tests/LocalStack.Client.Generators.Tests.csproj @@ -1,6 +1,6 @@ - net10.0 + $(LocalStackModernMinimumTargetFramework) false diff --git a/tests/LocalStack.Client.Integration.Tests/LocalStack.Client.Integration.Tests.csproj b/tests/LocalStack.Client.Integration.Tests/LocalStack.Client.Integration.Tests.csproj index 88d3f86..8545871 100644 --- a/tests/LocalStack.Client.Integration.Tests/LocalStack.Client.Integration.Tests.csproj +++ b/tests/LocalStack.Client.Integration.Tests/LocalStack.Client.Integration.Tests.csproj @@ -1,8 +1,8 @@  - net472;net10.0;net11.0 - $(NoWarn);CA1707;MA0006;CA1510 + $(LocalStackNetFrameworkAndModernTargetFrameworks) + $(NoWarn);CA1510 @@ -140,7 +140,7 @@ - + diff --git a/tests/LocalStack.Client.Tests/LocalStack.Client.Tests.csproj b/tests/LocalStack.Client.Tests/LocalStack.Client.Tests.csproj index 8cd0888..49dee78 100644 --- a/tests/LocalStack.Client.Tests/LocalStack.Client.Tests.csproj +++ b/tests/LocalStack.Client.Tests/LocalStack.Client.Tests.csproj @@ -1,8 +1,7 @@  - net472;net10.0;net11.0 - $(NoWarn);CA1707;MA0006 + $(LocalStackNetFrameworkAndModernTargetFrameworks) @@ -21,7 +20,7 @@ - + diff --git a/tests/common/LocalStack.Tests.Common/LocalStack.Tests.Common.csproj b/tests/common/LocalStack.Tests.Common/LocalStack.Tests.Common.csproj index fd34a37..4e7f870 100644 --- a/tests/common/LocalStack.Tests.Common/LocalStack.Tests.Common.csproj +++ b/tests/common/LocalStack.Tests.Common/LocalStack.Tests.Common.csproj @@ -1,7 +1,7 @@ - net472;net10.0;net11.0 + $(LocalStackNetFrameworkAndModernTargetFrameworks) $(NoWarn);CA1707;MA0006;CA1510 @@ -10,7 +10,7 @@ - + diff --git a/tests/sandboxes/LocalStack.Client.Sandbox.DependencyInjection/LocalStack.Client.Sandbox.DependencyInjection.csproj b/tests/sandboxes/LocalStack.Client.Sandbox.DependencyInjection/LocalStack.Client.Sandbox.DependencyInjection.csproj index 14fa35d..08cf6a7 100644 --- a/tests/sandboxes/LocalStack.Client.Sandbox.DependencyInjection/LocalStack.Client.Sandbox.DependencyInjection.csproj +++ b/tests/sandboxes/LocalStack.Client.Sandbox.DependencyInjection/LocalStack.Client.Sandbox.DependencyInjection.csproj @@ -2,7 +2,7 @@ Exe - net10.0;net11.0 + $(LocalStackModernTargetFrameworks) $(NoWarn);CS0246;S125;CA1305;CA1031;CA1303;CA1848;MA0004;CA2007 diff --git a/tests/sandboxes/LocalStack.Client.Sandbox.WithGenericHost/LocalStack.Client.Sandbox.WithGenericHost.csproj b/tests/sandboxes/LocalStack.Client.Sandbox.WithGenericHost/LocalStack.Client.Sandbox.WithGenericHost.csproj index 778435a..605c1cf 100644 --- a/tests/sandboxes/LocalStack.Client.Sandbox.WithGenericHost/LocalStack.Client.Sandbox.WithGenericHost.csproj +++ b/tests/sandboxes/LocalStack.Client.Sandbox.WithGenericHost/LocalStack.Client.Sandbox.WithGenericHost.csproj @@ -2,7 +2,7 @@ Exe - net10.0;net11.0 + $(LocalStackModernTargetFrameworks) latest $(NoWarn);CS0246;S125;CA1305;CA1031;CA1303;CA1848;CA1873;MA0004;CA2007 diff --git a/tests/sandboxes/LocalStack.Client.Sandbox/LocalStack.Client.Sandbox.csproj b/tests/sandboxes/LocalStack.Client.Sandbox/LocalStack.Client.Sandbox.csproj index edfdd3f..4e7d5fd 100644 --- a/tests/sandboxes/LocalStack.Client.Sandbox/LocalStack.Client.Sandbox.csproj +++ b/tests/sandboxes/LocalStack.Client.Sandbox/LocalStack.Client.Sandbox.csproj @@ -2,7 +2,7 @@ Exe - net472;net10.0;net11.0 + $(LocalStackNetFrameworkAndModernTargetFrameworks) $(NoWarn);CS0246;S125;CA1305;CA1031;CA1303;CA1848;MA0004;CA2007 diff --git a/tests/sandboxes/LocalStack.Container/LocalStack.Container.csproj b/tests/sandboxes/LocalStack.Container/LocalStack.Container.csproj index 9373e81..0a775c5 100644 --- a/tests/sandboxes/LocalStack.Container/LocalStack.Container.csproj +++ b/tests/sandboxes/LocalStack.Container/LocalStack.Container.csproj @@ -2,7 +2,7 @@ Exe - net10.0 + $(LocalStackModernMinimumTargetFramework) latest $(NoWarn);CS0246;S125;CA1305;CA1031;CA1303;CA1848;MA0004;CA2007