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..acf073d 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -68,23 +68,9 @@ jobs: uses: actions/setup-dotnet@v6 with: dotnet-version: | - 8.0.x - 9.0.x 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: @@ -169,8 +155,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/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.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/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/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/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('.', '-'); diff --git a/build/LocalStack.Build/LocalStack.Build.csproj b/build/LocalStack.Build/LocalStack.Build.csproj index 00369f3..7dbd321 100644 --- a/build/LocalStack.Build/LocalStack.Build.csproj +++ b/build/LocalStack.Build/LocalStack.Build.csproj @@ -1,7 +1,7 @@ Exe - net9.0 + $(LocalStackModernMinimumTargetFramework) $(MSBuildProjectDirectory) latest 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/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 7c89c70..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;net8.0;net9.0;net10.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 @@ -25,36 +25,35 @@ - + - - - - + + + + - + - - - + + + - - - + + - + - + - - + + @@ -64,11 +63,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..1b5f40d 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 + $(LocalStackClientTargetFrameworks) LocalStack.Client LocalStack.Client @@ -14,13 +14,17 @@ LICENSE.txt README.md true - 2.0.0 + $(LocalStackPackageValidationBaselineVersion) true true $(NoWarn);MA0006;CA1510 + + + + true @@ -28,8 +32,32 @@ + + + + + + + $(IntermediateOutputPath)buildTransitive/LocalStack.Client.props + + + + + + + + + + + + + + + @@ -51,8 +79,8 @@ - + 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..73f112e --- /dev/null +++ b/src/LocalStack.Client/buildTransitive/LocalStack.Client.targets @@ -0,0 +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 new file mode 100644 index 0000000..496915e --- /dev/null +++ b/tests/Directory.Build.targets @@ -0,0 +1,6 @@ + + + + + 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..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 @@  - net8.0;net9.0;net10.0 - $(NoWarn);CA1707;MA0006 + $(LocalStackModernTargetFrameworks) diff --git a/tests/LocalStack.Client.Extensions.Tests/OptionsBindingCompatibilityTests.cs b/tests/LocalStack.Client.Extensions.Tests/OptionsBindingCompatibilityTests.cs new file mode 100644 index 0000000..2a37c9f --- /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 AddLocalStack_Should_Bind_Options_Lazily_And_Keep_Nested_Options_Independent() + { + 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 AddLocalStack_Should_Convert_Integers_With_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 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 })); + 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 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")); + 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 AddLocalStack_Should_Keep_Framework_Order_For_Configure_PostConfigure_Validation_And_Monitor() + { + 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 AddLocalStack_Should_Keep_All_Defaults_When_Sections_Are_Missing() + { + 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 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" } + : 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 AddLocalStack_Should_Not_Publish_A_Partially_Bound_Nested_Object_When_Binding_Fails() + { + 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.Functional.Tests/LocalStack.Client.Functional.Tests.csproj b/tests/LocalStack.Client.Functional.Tests/LocalStack.Client.Functional.Tests.csproj index 414845b..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 @@  - net8.0;net9.0;net10.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 @@ -38,7 +38,6 @@ - diff --git a/tests/LocalStack.Client.Generators.Tests/GeneratorTests.cs b/tests/LocalStack.Client.Generators.Tests/GeneratorTests.cs new file mode 100644 index 0000000..b348331 --- /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 Generator_Should_Discover_Referenced_Sdk_Clients_Without_Registration_Code() + { + (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 Generator_Should_Keep_Type_Identity_When_Consumer_Declares_Global_Usings() + { + (_, 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 Generator_Should_Compile_When_Consumer_Declares_Global_Delegate_And_Attribute_Names(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 Generator_Should_Register_Private_Closed_Generic_Clients_Lazily_With_Their_Own_Metadata_And_Config() + { + (_, 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 Generator_Should_Not_Evaluate_Client_Or_Config_Operations_During_Registration() + { + (_, 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 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); + 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 Generator_Should_Discover_Inaccessible_Referenced_Configs_Without_Consumer_Syntax() + { + 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 Generator_Should_Register_Closed_Signature_Types_Lazily_Without_Consumer_Generic_Syntax() + { + 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 CreateClientConfig_Should_Accept_Types_Without_A_Client_Base_Class_Or_Metadata() + { + (_, 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..1fa447c --- /dev/null +++ b/tests/LocalStack.Client.Generators.Tests/LocalStack.Client.Generators.Tests.csproj @@ -0,0 +1,17 @@ + + + $(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 87083d2..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;net8.0;net9.0;net10.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 b6f7307..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;net8.0;net9.0;net10.0 - $(NoWarn);CA1707;MA0006 + $(LocalStackNetFrameworkAndModernTargetFrameworks) @@ -21,7 +20,7 @@ - + diff --git a/tests/LocalStack.Client.Tests/SessionTests/SessionCompatibilityTests.cs b/tests/LocalStack.Client.Tests/SessionTests/SessionCompatibilityTests.cs new file mode 100644 index 0000000..af4ba73 --- /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 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"), + 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 CreateClientByImplementation_Should_Validate_Region_Before_Calling_Injected_Reflection() + { + 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 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); + InvalidOperationException error = Assert.Throws(() => session.CreateClientByImplementation()); + Assert.Same(reflection.Failure, error); + Assert.Equal("metadata", Assert.Single(reflection.Calls)); + } + + [Fact] + public void SessionReflection_Should_Use_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 SetForcePathStyle_Should_Keep_Property_Type_Errors_And_Setter_Exception_Wrapping() + { + 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 ExtractServiceMetadata_Should_Not_Read_A_Private_Base_Class_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 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"); + 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 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()))); + 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; + } +} diff --git a/tests/common/LocalStack.Tests.Common/LocalStack.Tests.Common.csproj b/tests/common/LocalStack.Tests.Common/LocalStack.Tests.Common.csproj index 5f1c307..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;net8.0;net9.0;net10.0 + $(LocalStackNetFrameworkAndModernTargetFrameworks) $(NoWarn);CA1707;MA0006;CA1510 @@ -10,7 +10,7 @@ - + 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..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 - net8.0;net9.0;net10.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 237b944..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 - net8.0;net9.0;net10.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 6a7003e..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;net8.0;net9.0;net10.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 8e92cd2..0a775c5 100644 --- a/tests/sandboxes/LocalStack.Container/LocalStack.Container.csproj +++ b/tests/sandboxes/LocalStack.Container/LocalStack.Container.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + $(LocalStackModernMinimumTargetFramework) latest $(NoWarn);CS0246;S125;CA1305;CA1031;CA1303;CA1848;MA0004;CA2007