diff --git a/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj b/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj index aadc28b..ada19dd 100644 --- a/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj +++ b/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj @@ -2,6 +2,7 @@ $(LocalStackExtensionsTargetFrameworks) + $(IsLocalStackModernTarget) LocalStack.Client.Extensions LocalStack.Client.Extensions $(PackageExtensionVersion) diff --git a/src/LocalStack.Client.Generators/AwsRegistryGenerator.cs b/src/LocalStack.Client.Generators/AwsRegistryGenerator.cs index 1f374a3..8faa1e0 100644 --- a/src/LocalStack.Client.Generators/AwsRegistryGenerator.cs +++ b/src/LocalStack.Client.Generators/AwsRegistryGenerator.cs @@ -1,10 +1,11 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; using System.Threading; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; @@ -13,13 +14,39 @@ namespace LocalStack.Client.Generators; [Generator(LanguageNames.CSharp)] public sealed class AwsRegistryGenerator : IIncrementalGenerator { + private const string Runtime = "global::Amazon.Runtime."; + private const string Metadata = "global::Amazon.Runtime.Internal.IServiceMetadata"; + private static readonly ConditionalWeakTable> References = new(); + 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); + IncrementalValuesProvider declarations = context.SyntaxProvider.CreateSyntaxProvider( + static (node, _) => node is ClassDeclarationSyntax { BaseList: not null }, + static (syntax, token) => syntax.SemanticModel.GetDeclaredSymbol((ClassDeclarationSyntax)syntax.Node, token) is INamedTypeSymbol type + ? MetadataName(type) : ""); + // String equality prevents unchanged plans from invalidating emission. + // Symbols remain inside the classification transform. + var referenceContext = context.CompilationProvider.Select(static (compilation, token) => PrepareReferences(compilation, token)) + .WithTrackingName("ReferenceContext"); + var references = context.MetadataReferencesProvider.Combine(referenceContext).SelectMany(static (input, _) => + { + if (References.TryGetValue(input.Left, out Dictionary? cache)) + { + lock (cache) + { + if (cache.TryGetValue(input.Right.CoreIdentity, out ReferenceModel? model) && + input.Right.Assemblies.IndexOf("\n" + model.AssemblyIdentity + "\n", StringComparison.Ordinal) >= 0) + { + return ImmutableArray.Create(model); + } + } + } + + return ImmutableArray.Empty; + }).WithTrackingName("ReferenceDiscovery"); + IncrementalValueProvider source = declarations.Collect().Combine(references.Collect()).Combine(context.CompilationProvider) + .Select(static (input, token) => Generate(input.Right, input.Left.Left, input.Left.Right, token)) + .WithComparer(StringComparer.Ordinal).WithTrackingName("RegistryPlan"); context.RegisterSourceOutput(source, static (output, text) => { if (text.Length != 0) @@ -29,241 +56,318 @@ public void Initialize(IncrementalGeneratorInitializationContext context) }); } - private static string Generate(Compilation compilation, CancellationToken cancellationToken) + private static string Generate(Compilation compilation, IEnumerable declarations, IEnumerable references, CancellationToken token) { + // Private SDK fields are omitted by the consumer's default metadata import policy. + // Rebind symbols with full metadata; the emitted code still uses exact accessors. + compilation = compilation.WithOptions(compilation.Options.WithMetadataImportOptions(MetadataImportOptions.All)); 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) + INamedTypeSymbol? credentials = compilation.GetTypeByMetadataName("Amazon.Runtime.AWSCredentials"); + if (clientBase == null || configBase == null || credentials == null || + compilation.GetTypeByMetadataName("LocalStack.Client.Session") == null || + compilation.GetTypeByMetadataName("System.Runtime.CompilerServices.UnsafeAccessorTypeAttribute") == null || + compilation.GetSpecialType(SpecialType.System_Object).ContainingAssembly.Identity.Version.Major < 10) { return ""; } var clients = new HashSet(SymbolEqualityComparer.Default); var configs = new HashSet(SymbolEqualityComparer.Default); - void Discover(INamedTypeSymbol type) + void Discover(INamedTypeSymbol? type, bool own) { - cancellationToken.ThrowIfCancellationRequested(); - if (ContainsTypeParameter(type)) + token.ThrowIfCancellationRequested(); + if (type == null || (!DerivesFrom(type, clientBase) && !(own && DerivesFrom(type, configBase))) || !CanName(compilation, type)) { return; } - if (DerivesFrom(type, configBase)) + if (!type.IsAbstract && DerivesFrom(type, clientBase)) { - configs.Add(type); + clients.Add(type); } - else if (DerivesFrom(type, clientBase) || type.GetMembers("serviceMetadata").OfType().Any() || HasConfigurationConstructor(type, clientBase, configBase)) + else if (own && !Same(type, configBase) && DerivesFrom(type, configBase) && CanUseConfig(compilation, type)) { - clients.Add(type); + configs.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) + foreach (string name in declarations.Distinct(StringComparer.Ordinal)) { - 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); - } - } + Discover(compilation.Assembly.GetTypeByMetadataName(name), own: true); } - foreach (IParameterSymbol parameter in clients.SelectMany(client => client.InstanceConstructors).SelectMany(constructor => constructor.Parameters)) + Dictionary assemblies = compilation.References.Select(compilation.GetAssemblyOrModuleSymbol).OfType() + .GroupBy(assembly => assembly.Identity.GetDisplayName(), StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal); + foreach (ReferenceModel reference in references) { - cancellationToken.ThrowIfCancellationRequested(); - if (parameter.Type is INamedTypeSymbol type && !ContainsTypeParameter(type) && DerivesFrom(type, configBase)) + if (assemblies.TryGetValue(reference.AssemblyIdentity, out IAssemblySymbol? assembly)) { - configs.Add(type); + foreach (string name in reference.Identities) + { + Discover(assembly.GetTypeByMetadataName(name), own: false); + } } } - - 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)); + return Emit(compilation, clients, configs, credentials, configBase); } - private static void DiscoverSignature(ITypeSymbol type, HashSet visited, Action discover, CancellationToken cancellationToken) + private static (string CoreIdentity, string Assemblies) PrepareReferences(Compilation compilation, CancellationToken token) { - cancellationToken.ThrowIfCancellationRequested(); - if (!visited.Add(type)) + INamedTypeSymbol? clientBase = compilation.GetTypeByMetadataName("Amazon.Runtime.AmazonServiceClient"); + INamedTypeSymbol? configBase = compilation.GetTypeByMetadataName("Amazon.Runtime.ClientConfig"); + if (clientBase == null || configBase == null) { - return; + return ("", ""); } - if (type is IArrayTypeSymbol array) + IAssemblySymbol[] assemblies = compilation.References.Select(compilation.GetAssemblyOrModuleSymbol).OfType().ToArray(); + var eligible = new HashSet(StringComparer.Ordinal) { clientBase.ContainingAssembly.Name }; + bool changed; + do { - DiscoverSignature(array.ElementType, visited, discover, cancellationToken); + changed = false; + foreach (IAssemblySymbol assembly in assemblies.Where(assembly => + assembly.Modules.Any(module => module.ReferencedAssemblies.Any(dependency => eligible.Contains(dependency.Name))))) + { + changed |= eligible.Add(assembly.Name); + } } - else if (type is INamedTypeSymbol named) + while (changed); + + string coreIdentity = clientBase.ContainingAssembly.Identity.GetDisplayName(); + var identities = new StringBuilder("\n"); + foreach (MetadataReference reference in compilation.References) { - discover(named); - foreach (ITypeSymbol argument in named.TypeArguments) + if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol assembly || !eligible.Contains(assembly.Name)) { - DiscoverSignature(argument, visited, discover, cancellationToken); + continue; } - if (named.BaseType != null) + Dictionary cache = References.GetValue(reference, _ => new Dictionary(StringComparer.Ordinal)); + lock (cache) { - DiscoverSignature(named.BaseType, visited, discover, cancellationToken); + if (!cache.ContainsKey(coreIdentity)) + { + cache.Add(coreIdentity, DiscoverReference(assembly, clientBase, configBase, token)); + } } + + identities.Append(assembly.Identity.GetDisplayName()).Append('\n'); } + + return (coreIdentity, identities.ToString()); } - private static void DiscoverReferences(Compilation compilation, INamedTypeSymbol clientBase, Action discover, CancellationToken cancellationToken) + private static string Emit(Compilation compilation, HashSet clients, HashSet configs, + INamedTypeSymbol credentials, INamedTypeSymbol configBase) { - // 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))) + var operations = new List(); + var registrations = new List(); + foreach (INamedTypeSymbol client in clients.OrderBy(Identity, StringComparer.Ordinal)) { - VisitNamespace(assembly.GlobalNamespace, discover, cancellationToken); + string suffix = operations.Count.ToString(System.Globalization.CultureInfo.InvariantCulture); + string? operation = ClientOperation(compilation, client, credentials, configBase, configs, suffix); + if (operation != null) + { + operations.Add(operation); + registrations.Add("RegisterClient(null!, typeof(" + Name(client) + "), Metadata" + suffix + ", Config" + suffix + ", Create" + suffix + ");"); + } } - } - private static bool ReferencesAssembly(IAssemblySymbol assembly, IAssemblySymbol target, HashSet visited, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - if (SymbolEqualityComparer.Default.Equals(assembly, target)) + foreach (INamedTypeSymbol config in configs.OrderBy(Identity, StringComparer.Ordinal)) { - return true; + string? style = StyleOperation(compilation, config, configBase); + bool plainRegion = !Hierarchy(config, configBase).Any(type => type.GetMembers("RegionEndpoint").Length != 0); + registrations.Add("RegisterConfig(null!, typeof(" + Name(config) + "), " + (style ?? "null") + ", " + (plainRegion ? "true" : "false") + ");"); } - return visited.Add(assembly) && assembly.Modules.Any(module => module.ReferencedAssemblySymbols.Any(reference => ReferencesAssembly(reference, target, visited, cancellationToken))); + var source = new StringBuilder(Header); + source.AppendLine("[global::System.Runtime.CompilerServices.ModuleInitializerAttribute] internal static void Initialize() {"); + for (int index = 0; index < registrations.Count; index++) + { + source.Append("try { Register").Append(index).AppendLine("(); } catch (global::System.MissingMethodException) { } catch (global::System.IO.FileNotFoundException) { } catch (global::System.IO.FileLoadException) { } catch (global::System.TypeLoadException) { } catch (global::System.BadImageFormatException) { }"); + } + + source.AppendLine("}"); + for (int index = 0; index < registrations.Count; index++) + { + source.AppendLine("[global::System.Runtime.CompilerServices.MethodImplAttribute(global::System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]"); + source.Append("private static void Register").Append(index).Append("() { ").Append(registrations[index]).AppendLine(" }"); + } + foreach (string operation in operations) + { + source.AppendLine(operation); + } + + return source.AppendLine("} }").ToString(); } - private static string Emit(Compilation compilation, HashSet clients, HashSet configs) + private static string? ClientOperation(Compilation compilation, INamedTypeSymbol client, INamedTypeSymbol credentials, + INamedTypeSymbol configBase, HashSet configs, string suffix) { - 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++) + IMethodSymbol[] constructors = client.InstanceConstructors.Where(ctor => ctor.DeclaredAccessibility == Accessibility.Public).ToArray(); + if (constructors.Any(ctor => ctor.Parameters.Any(parameter => parameter.IsParams) || + (ctor.Parameters.Length > 2 && ctor.Parameters.Skip(2).All(parameter => parameter.IsOptional)) || + (ctor.Parameters.Length == 2 && + ((!Same(ctor.Parameters[0].Type, credentials) && + (compilation.ClassifyCommonConversion(credentials, ctor.Parameters[0].Type).IsImplicit || + ctor.Parameters[0].Type is INamedTypeSymbol first && DerivesFrom(first, credentials))) || + (ctor.Parameters[1].Type is INamedTypeSymbol other && !DerivesFrom(other, configBase) && + (other.TypeKind == TypeKind.Interface || compilation.ClassifyCommonConversion(configBase, other).IsImplicit)))))) { - 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(");"); + return null; } - for (int index = 0; index < orderedConfigs.Length; index++) + IMethodSymbol[] candidates = constructors.Where(ctor => ctor.Parameters.Length == 2 && Same(ctor.Parameters[0].Type, credentials) && + ctor.Parameters[1].Type is INamedTypeSymbol config && DerivesFrom(config, configBase)).ToArray(); + if (candidates.Any(ctor => ctor.Parameters.Any(parameter => parameter.RefKind != RefKind.None) || !CanConstruct(client, ctor) || + !CanUseConfig(compilation, (INamedTypeSymbol)ctor.Parameters[1].Type)) || + candidates.Any(first => candidates.Any(second => !DerivesFrom((INamedTypeSymbol)first.Parameters[1].Type, (INamedTypeSymbol)second.Parameters[1].Type) && + !DerivesFrom((INamedTypeSymbol)second.Parameters[1].Type, (INamedTypeSymbol)first.Parameters[1].Type)))) { - 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(");"); + return null; } - 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);"); + IFieldSymbol? field = client.GetMembers("serviceMetadata").OfType() + .FirstOrDefault(member => member.IsStatic && member.DeclaredAccessibility != Accessibility.Public); + if (field != null && (!compilation.IsSymbolAccessibleWithin(field.Type, compilation.Assembly) || HasDiagnosticAttribute(field.Type))) + { + return null; } - for (int index = 0; index < orderedConfigs.Length; index++) + string clientName = Name(client); + var text = new StringBuilder(); + if (field != null) { - 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);"); + text.AppendLine("[global::System.Runtime.CompilerServices.UnsafeAccessorAttribute(global::System.Runtime.CompilerServices.UnsafeAccessorKind.StaticField, Name = \"serviceMetadata\")]"); + text.Append("private static extern ref ").Append(Name(field.Type)).Append(" Field").Append(suffix).Append('(').Append(clientName).AppendLine("? owner);"); } - source.AppendLine("private static ClientConfig CreateConfig(Type type) {"); - foreach (INamedTypeSymbol config in orderedConfigs) + text.Append("private static ").Append(Metadata).Append(" Metadata").Append(suffix).Append("() => "); + text.AppendLine(field == null + ? "throw new global::System.InvalidOperationException($\"Invalid service type {typeof(" + clientName + ")}\");" + : "(" + Metadata + ")(object)Field" + suffix + "(null)!;"); + text.Append(ConfigOperation(candidates, configBase, configs, suffix)); + text.Append("private static ").Append(Runtime).Append("AmazonServiceClient? Create").Append(suffix).Append('(').Append(Runtime) + .Append("AWSCredentials credentials, ").Append(Runtime).AppendLine("ClientConfig config) {"); + int arm = 0; + foreach (IMethodSymbol constructor in candidates.OrderByDescending(ctor => Hierarchy((INamedTypeSymbol)ctor.Parameters[1].Type, configBase).Count())) { - string type = TypeExpression(compilation, config); - source.Append("if (type == ").Append(type).Append(") return (ClientConfig)Activator.CreateInstance(").Append(type).AppendLine(")!;"); + string variable = "config" + arm.ToString(System.Globalization.CultureInfo.InvariantCulture); + text.Append("if (config is ").Append(Name(constructor.Parameters[1].Type)).Append(' ').Append(variable).Append(") return new ") + .Append(clientName).Append("(credentials, ").Append(variable).AppendLine(");"); + arm++; } - source.AppendLine("throw new InvalidOperationException($\"Invalid client configuration type {type}\"); }"); - return source.Append(GeneratedReflection.Methods).AppendLine("}").ToString(); + return text.AppendLine("return null; }").ToString(); } - private static void VisitNamespace(INamespaceSymbol space, Action visit, CancellationToken cancellationToken) + private static string ConfigOperation(IMethodSymbol[] candidates, INamedTypeSymbol configBase, HashSet configs, string suffix) { - foreach (INamespaceOrTypeSymbol member in space.GetMembers()) + IMethodSymbol[] selected = candidates.Where(ctor => string.Equals(ctor.Parameters[0].Name, "credentials", StringComparison.Ordinal) && + string.Equals(ctor.Parameters[1].Name, "clientConfig", StringComparison.Ordinal) && !Same(ctor.Parameters[1].Type, configBase)).ToArray(); + var text = new StringBuilder(); + text.Append("private static ").Append(Runtime).Append("ClientConfig Config").Append(suffix).Append("() { "); + if (selected.Length != 1) { - cancellationToken.ThrowIfCancellationRequested(); - if (member is INamespaceSymbol child) + text.Append("throw new global::System.InvalidOperationException(\"Sequence contains ") + .Append(selected.Length == 0 ? "no matching element" : "more than one matching element").AppendLine("\"); }"); + } + else + { + var config = (INamedTypeSymbol)selected[0].Parameters[1].Type; + configs.Add(config); + if (config.IsAbstract || !config.InstanceConstructors.Any(ctor => ctor.DeclaredAccessibility == Accessibility.Public && ctor.Parameters.Length == 0)) { - VisitNamespace(child, visit, cancellationToken); + text.AppendLine("throw new global::System.MissingMethodException(); }"); } - else if (member is INamedTypeSymbol type) + else { - VisitType(type, visit, cancellationToken); + text.Append("try { return new ").Append(Name(config)).AppendLine("(); } catch (global::System.Exception e) { throw new global::System.Reflection.TargetInvocationException(e); } }"); } } + + return text.ToString(); + } + + private static string? StyleOperation(Compilation compilation, INamedTypeSymbol config, INamedTypeSymbol configBase) + { + ISymbol[] members = Hierarchy(config, configBase).SelectMany(type => type.GetMembers("ForcePathStyle").Concat( + type.GetMembers("this[]").Where(member => string.Equals(member.MetadataName, "ForcePathStyle", StringComparison.Ordinal)))).ToArray(); + if (members.Length == 0) + { + return "(c, v) => false"; + } + + if (members.Length != 1 || members[0] is not IPropertySymbol { IsStatic: false, IsIndexer: false } property || + property.Type.SpecialType != SpecialType.System_Boolean || property.GetMethod?.DeclaredAccessibility != Accessibility.Public || + property.SetMethod?.DeclaredAccessibility != Accessibility.Public || property.SetMethod.IsInitOnly || HasDiagnosticAttribute(property) || + !compilation.IsSymbolAccessibleWithin(property.ContainingType, compilation.Assembly)) + { + return null; + } + + return "(c, v) => { try { ((" + Name(config) + ")c).ForcePathStyle = v; } catch (global::System.Exception e) { throw new global::System.Reflection.TargetInvocationException(e); } return true; }"; } - private static void VisitType(INamedTypeSymbol type, Action visit, CancellationToken cancellationToken) + private static ReferenceModel DiscoverReference(IAssemblySymbol assembly, INamedTypeSymbol clientBase, INamedTypeSymbol configBase, CancellationToken token) { - 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()) + var names = new List(); + void VisitType(INamedTypeSymbol type) { - cancellationToken.ThrowIfCancellationRequested(); - switch (member) + token.ThrowIfCancellationRequested(); + if (type.DeclaredAccessibility != Accessibility.Public) { - case IMethodSymbol method: - visit(method.ReturnType); - foreach (IParameterSymbol parameter in method.Parameters) - { - visit(parameter.Type); - } + return; + } - break; - case IPropertySymbol property: - visit(property.Type); - break; - case IFieldSymbol field: - visit(field.Type); - break; + if (DerivesFrom(type, clientBase) || DerivesFrom(type, configBase)) + { + names.Add(MetadataName(type)); + } + foreach (INamedTypeSymbol nested in type.GetTypeMembers()) + { + VisitType(nested); } } - foreach (INamedTypeSymbol nested in type.GetTypeMembers()) + void VisitNamespace(INamespaceSymbol space) { - VisitType(nested, visit, cancellationToken); + foreach (INamespaceSymbol child in space.GetNamespaceMembers()) + { + VisitNamespace(child); + } + + foreach (INamedTypeSymbol type in space.GetTypeMembers()) + { + VisitType(type); + } } + + VisitNamespace(assembly.GlobalNamespace); + return new ReferenceModel(assembly.Identity.GetDisplayName(), names.ToImmutableArray()); } - private static bool ContainsTypeParameter(ITypeSymbol type) => type switch + private static bool CanUseConfig(Compilation compilation, INamedTypeSymbol config) { - 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) + IMethodSymbol? constructor = config.InstanceConstructors.FirstOrDefault(ctor => ctor.DeclaredAccessibility == Accessibility.Public && ctor.Parameters.Length == 0); + return CanName(compilation, config) && (constructor == null || CanConstruct(config, constructor)); + } + + private static bool CanConstruct(INamedTypeSymbol type, IMethodSymbol constructor) => !HasDiagnosticAttribute(constructor) && + (!Hierarchy(type, null).Any(current => + current.GetMembers().Any(member => member is IPropertySymbol { IsRequired: true } or IFieldSymbol { IsRequired: true })) || + constructor.GetAttributes().Any(attribute => string.Equals(attribute.AttributeClass?.ToDisplayString(), + "System.Diagnostics.CodeAnalysis.SetsRequiredMembersAttribute", StringComparison.Ordinal))); + + private static bool HasDiagnosticAttribute(ISymbol symbol) { - for (INamedTypeSymbol? current = type; current != null; current = current.BaseType) + for (ISymbol? current = symbol; current != null; current = current.ContainingType) { - if (SymbolEqualityComparer.Default.Equals(current, baseType)) + if (current.GetAttributes().Any(attribute => attribute.AttributeClass?.ToDisplayString() is + "System.ObsoleteAttribute" or "System.Diagnostics.CodeAnalysis.ExperimentalAttribute")) { return true; } @@ -272,61 +376,102 @@ private static bool DerivesFrom(INamedTypeSymbol type, INamedTypeSymbol baseType return false; } - private static string TypeExpression(Compilation compilation, INamedTypeSymbol type) + private static bool CanName(Compilation compilation, INamedTypeSymbol type) { - if (compilation.IsSymbolAccessibleWithin(type, compilation.Assembly) && - SymbolEqualityComparer.Default.Equals(compilation.GetTypeByMetadataName(MetadataName(type)), type.OriginalDefinition)) + if (HasDiagnosticAttribute(type)) { - return "typeof(" + type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ")"; + return false; } - // 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)!"; - } + for (INamedTypeSymbol? current = type; current != null; current = current.ContainingType) + { + if (current.IsGenericType || current.IsFileLocal || current.DeclaredAccessibility is not (Accessibility.Public or Accessibility.Internal)) + { + return false; + } + } - 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; + MetadataReference? reference = compilation.GetMetadataReference(type.ContainingAssembly); + return compilation.IsSymbolAccessibleWithin(type, compilation.Assembly) && + (Same(type.ContainingAssembly, compilation.Assembly) || reference != null && + (reference.Properties.Aliases.IsDefaultOrEmpty || reference.Properties.Aliases.Contains("global", StringComparer.Ordinal))) && + HasUniqueName(compilation, type); } - 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) + private static bool HasUniqueName(Compilation compilation, INamedTypeSymbol type) { - if (type is IArrayTypeSymbol array) + if (type.ContainingType != null) { - return ReflectionName(array.ElementType) + "[" + new string(',', array.Rank - 1) + "]"; + return HasUniqueName(compilation, type.ContainingType); } - if (type is not INamedTypeSymbol named) + var names = new Stack(); + for (INamespaceSymbol space = type.ContainingNamespace; !space.IsGlobalNamespace; space = space.ContainingNamespace) { - return "System.Object"; // dynamic is System.Object in runtime type identity. + names.Push(space.Name); } - var arguments = new List(); - CollectArguments(named, arguments); - string name = MetadataName(named); - if (arguments.Count != 0) + INamespaceSymbol current = compilation.GlobalNamespace; + while (names.Count != 0) { - name += "[" + string.Join(",", arguments.Select(argument => "[" + AssemblyQualifiedName(argument) + "]")) + "]"; + INamespaceOrTypeSymbol[] members = current.GetMembers(names.Pop()).ToArray(); + if (members.Length != 1 || members[0] is not INamespaceSymbol next) + { + return false; + } + + current = next; } - return name; + ImmutableArray types = current.GetTypeMembers(type.Name, type.Arity); + return types.Length == 1 && Same(types[0], type); } - private static void CollectArguments(INamedTypeSymbol type, List arguments) + private static IEnumerable Hierarchy(INamedTypeSymbol type, INamedTypeSymbol? stop) { - if (type.ContainingType != null) + for (INamedTypeSymbol? current = type; current != null && !Same(current, stop); current = current.BaseType) { - CollectArguments(type.ContainingType, arguments); + yield return current; } + } + + private static bool DerivesFrom(INamedTypeSymbol type, INamedTypeSymbol baseType) => + Same(type, baseType) || Hierarchy(type, baseType).LastOrDefault()?.BaseType is INamedTypeSymbol last && Same(last, baseType); + + private static bool Same(ISymbol? first, ISymbol? second) => SymbolEqualityComparer.Default.Equals(first, second); + + private static string Name(ITypeSymbol type) => type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - arguments.AddRange(type.TypeArguments); + private static string Identity(INamedTypeSymbol type) => type.ContainingAssembly.Identity.GetDisplayName() + ":" + MetadataName(type); + + 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 sealed class ReferenceModel(string assemblyIdentity, ImmutableArray identities) : IEquatable + { + public string AssemblyIdentity { get; } = assemblyIdentity; + public ImmutableArray Identities { get; } = identities; + public bool Equals(ReferenceModel? other) => other != null && string.Equals(AssemblyIdentity, other.AssemblyIdentity, StringComparison.Ordinal) && + Identities.SequenceEqual(other.Identities, StringComparer.Ordinal); + public override bool Equals(object? obj) => obj is ReferenceModel other && Equals(other); + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(AssemblyIdentity); } + + private const string Header = """ + // + #nullable enable + 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, global::System.Type type, + global::System.Func metadata, global::System.Func config, + 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, global::System.Type type, + global::System.Func? style, bool plainRegion); + + """; } diff --git a/src/LocalStack.Client.Generators/GeneratedReflection.cs b/src/LocalStack.Client.Generators/GeneratedReflection.cs deleted file mode 100644 index 2e936bb..0000000 --- a/src/LocalStack.Client.Generators/GeneratedReflection.cs +++ /dev/null @@ -1,64 +0,0 @@ -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/LocalStack.Client.csproj b/src/LocalStack.Client/LocalStack.Client.csproj index 1b5f40d..de13740 100644 --- a/src/LocalStack.Client/LocalStack.Client.csproj +++ b/src/LocalStack.Client/LocalStack.Client.csproj @@ -2,6 +2,7 @@ $(LocalStackClientTargetFrameworks) + $(IsLocalStackModernTarget) LocalStack.Client LocalStack.Client diff --git a/src/LocalStack.Client/Properties/AssemblyInfo.cs b/src/LocalStack.Client/Properties/AssemblyInfo.cs index 4da7d9c..577911a 100644 --- a/src/LocalStack.Client/Properties/AssemblyInfo.cs +++ b/src/LocalStack.Client/Properties/AssemblyInfo.cs @@ -1,3 +1,5 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("LocalStack.Client.Extensions")] +[assembly: InternalsVisibleTo("LocalStack.Client.Tests")] +[assembly: InternalsVisibleTo("LocalStack.Client.Integration.Tests")] diff --git a/src/LocalStack.Client/Session.cs b/src/LocalStack.Client/Session.cs index f9772bb..0d8ad39 100644 --- a/src/LocalStack.Client/Session.cs +++ b/src/LocalStack.Client/Session.cs @@ -8,7 +8,7 @@ public class Session : ISession private readonly ISessionOptions _sessionOptions; private readonly ISessionReflection _sessionReflection; -#if NET8_0_OR_GREATER +#if NET10_0_OR_GREATER [DynamicDependency(nameof(RegisterGeneratedClient), typeof(Session))] [DynamicDependency(nameof(RegisterGeneratedConfig), typeof(Session))] #endif @@ -56,6 +56,13 @@ public AmazonServiceClient CreateClientByImplementation( clientConfig.RegionEndpoint = RegionEndpoint.GetBySystemName(_sessionOptions.RegionName); } +#if NET10_0_OR_GREATER + if (implType == null) + { + throw new ArgumentNullException(nameof(implType)); + } +#endif + var clientInstance = CreateImplementation(implType, awsCredentials, clientConfig); return clientInstance; @@ -84,7 +91,7 @@ public AmazonServiceClient CreateClientByInterface( } var clientTypeName = $"{serviceInterfaceType.Namespace}.{serviceInterfaceType.Name.Substring(1)}Client"; - Type? clientType = FindClientType(serviceInterfaceType.GetTypeInfo().Assembly, clientTypeName); + Type? clientType = FindClientType(serviceInterfaceType.Assembly, clientTypeName); if (clientType == null) { @@ -119,17 +126,15 @@ public AmazonServiceClient CreateClientByInterface( clientConfig.RegionEndpoint = RegionEndpoint.GetBySystemName(_sessionOptions.RegionName); } - ConstructorInfo? constructor = FindClientConstructor(clientType, clientConfig.GetType()); + AmazonServiceClient? client = CreateInterface(clientType, awsCredentials, clientConfig); - if (constructor == null) + if (client == null) { var message = $"Service client {clientTypeName} missing a constructor with parameters AWSCredentials and {clientConfig.GetType().FullName}."; throw new AmazonClientException(message); } - var client = (AmazonServiceClient)constructor.Invoke(new object[] { awsCredentials, clientConfig }); - return client; } @@ -177,72 +182,103 @@ private AmazonServiceClient CreateClientByStaticFactory(bool useService private static Type? FindClientType(Assembly assembly, string name) { -#if NET8_0_OR_GREATER +#if NET10_0_OR_GREATER Type? generated = GeneratedClientRegistry.FindClient(assembly, name); - if (generated != null || !System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported) + if (generated != null) { return generated; } -#endif + if (ReflectionFallback.IsSupported) + { + return FindLegacyClientType(assembly, name); + } + + return null; +#else return FindLegacyClientType(assembly, name); +#endif } - private static ConstructorInfo? FindClientConstructor(Type type, Type configType) + private static AmazonServiceClient? CreateInterface(Type type, AWSCredentials credentials, ClientConfig config) { -#if NET8_0_OR_GREATER +#if NET10_0_OR_GREATER if (GeneratedClientRegistry.TryGetClient(type, out GeneratedClientRegistry.ClientEntry? entry)) { - return entry.Constructor(configType); + return CreateGenerated(entry, credentials, config); } - if (!System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported) + if (ReflectionFallback.IsSupported) + { + return CreateLegacyInterface(type, credentials, config); + } + else { return null; } +#else + return CreateLegacyInterface(type, credentials, config); #endif - return FindLegacyClientConstructor(type, configType); } private static AmazonServiceClient CreateImplementation(Type type, AWSCredentials credentials, ClientConfig config) { -#if NET8_0_OR_GREATER +#if NET10_0_OR_GREATER if (GeneratedClientRegistry.TryGetClient(type, out GeneratedClientRegistry.ClientEntry? entry)) { - return entry.Create(credentials, config); + return CreateGenerated(entry, credentials, config) + ?? throw new MissingMethodException($"Constructor on type '{type.FullName}' not found."); } - if (!System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported) + if (ReflectionFallback.IsSupported) + { + return CreateLegacyImplementation(type, credentials, config); + } + else { throw new NotSupportedException($"Client type {type} was not present when the application was compiled."); } -#endif +#else return CreateLegacyImplementation(type, credentials, config); +#endif } -#if NET8_0_OR_GREATER +#if NET10_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 +#if NET10_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 }); + private static AmazonServiceClient? CreateLegacyInterface(Type type, AWSCredentials credentials, ClientConfig config) => + (AmazonServiceClient?)type.GetConstructor(new[] { typeof(AWSCredentials), config.GetType() })?.Invoke(new object[] { credentials, config }); + +#if NET10_0_OR_GREATER + private static AmazonServiceClient? CreateGenerated(GeneratedClientRegistry.ClientEntry entry, AWSCredentials credentials, ClientConfig config) + { + try + { + return entry.Create(credentials, config); + } + catch (Exception exception) + { + throw new TargetInvocationException(exception); + } + } -#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 +#if NET10_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); + Func create) => + GeneratedClientRegistry.RegisterClient(type, metadata, config, create); - private static void RegisterGeneratedConfig(Type type, Func style, Action region) => - GeneratedClientRegistry.RegisterConfig(type, style, region); + private static void RegisterGeneratedConfig(Type type, Func? style, bool plainRegion) => + GeneratedClientRegistry.RegisterConfig(type, style, plainRegion); #endif } diff --git a/src/LocalStack.Client/Utils/GeneratedClientRegistry.cs b/src/LocalStack.Client/Utils/GeneratedClientRegistry.cs index ba4e2b7..d72374b 100644 --- a/src/LocalStack.Client/Utils/GeneratedClientRegistry.cs +++ b/src/LocalStack.Client/Utils/GeneratedClientRegistry.cs @@ -1,4 +1,4 @@ -#if NET8_0_OR_GREATER +#if NET10_0_OR_GREATER using System.Collections.Concurrent; namespace LocalStack.Client.Utils; @@ -12,17 +12,17 @@ internal static class GeneratedClientRegistry private static readonly ConcurrentDictionary Configs = new(); public static void RegisterClient(Type type, Func metadata, Func config, - Func constructor, Func create) + Func create) { - Clients.TryAdd(type, new ClientEntry(metadata, config, constructor, create)); + Clients.TryAdd(type, new ClientEntry(metadata, config, 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 void RegisterConfig(Type type, Func? style, bool plainRegion) => + Configs.TryAdd(type, new ConfigEntry(style, plainRegion)); public static Type? FindClient(Assembly assembly, string name) => ClientNames.TryGetValue((assembly, name), out Type? type) ? type : null; @@ -34,9 +34,9 @@ public static bool TryGetClient(Type type, [NotNullWhen(true)] out ClientEntry? 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 ClientEntry(Func Metadata, Func CreateConfig, + Func Create); - internal sealed record ConfigEntry(Func Style, Action Region); + internal sealed record ConfigEntry(Func? Style, bool PlainRegion); } #endif diff --git a/src/LocalStack.Client/Utils/ReflectionFallback.cs b/src/LocalStack.Client/Utils/ReflectionFallback.cs new file mode 100644 index 0000000..9063225 --- /dev/null +++ b/src/LocalStack.Client/Utils/ReflectionFallback.cs @@ -0,0 +1,10 @@ +namespace LocalStack.Client.Utils; + +internal static class ReflectionFallback +{ +#if NET10_0_OR_GREATER + [FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))] +#endif + internal static bool IsSupported { get; } = + !AppContext.TryGetSwitch("LocalStack.Client.ReflectionFallback.IsSupported", out bool enabled) || enabled; +} diff --git a/src/LocalStack.Client/Utils/SessionReflection.cs b/src/LocalStack.Client/Utils/SessionReflection.cs index 81f2060..07e291c 100644 --- a/src/LocalStack.Client/Utils/SessionReflection.cs +++ b/src/LocalStack.Client/Utils/SessionReflection.cs @@ -17,21 +17,26 @@ public IServiceMetadata ExtractServiceMetadata(Type clientType) throw new ArgumentNullException(nameof(clientType)); } -#if NET8_0_OR_GREATER +#if NET10_0_OR_GREATER if (GeneratedClientRegistry.TryGetClient(clientType, out GeneratedClientRegistry.ClientEntry? entry)) { return entry.Metadata(); } - if (!System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported) + if (ReflectionFallback.IsSupported) + { + return ExtractLegacyServiceMetadata(clientType); + } + else { throw new InvalidOperationException($"Invalid service type {clientType}"); } -#endif +#else return ExtractLegacyServiceMetadata(clientType); +#endif } -#if NET8_0_OR_GREATER +#if NET10_0_OR_GREATER [RequiresUnreferencedCode("Dynamically loaded client metadata requires its original private fields.")] #endif private static IServiceMetadata ExtractLegacyServiceMetadata(Type clientType) @@ -59,21 +64,26 @@ public ClientConfig CreateClientConfig(Type clientType) throw new ArgumentNullException(nameof(clientType)); } -#if NET8_0_OR_GREATER +#if NET10_0_OR_GREATER if (GeneratedClientRegistry.TryGetClient(clientType, out GeneratedClientRegistry.ClientEntry? entry)) { - return entry.Config(); + return entry.CreateConfig(); } - if (!System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported) + if (ReflectionFallback.IsSupported) + { + return CreateLegacyClientConfig(clientType); + } + else { throw new InvalidOperationException("Sequence contains no matching element"); } -#endif +#else return CreateLegacyClientConfig(clientType); +#endif } -#if NET8_0_OR_GREATER +#if NET10_0_OR_GREATER [RequiresUnreferencedCode("Dynamically loaded configurations require their original constructors.")] #endif private static ClientConfig CreateLegacyClientConfig(Type clientType) @@ -91,22 +101,40 @@ public void SetClientRegion(AmazonServiceClient amazonServiceClient, string syst throw new ArgumentNullException(nameof(amazonServiceClient)); } -#if NET8_0_OR_GREATER - if (GeneratedClientRegistry.TryGetConfig(amazonServiceClient.Config.GetType(), out GeneratedClientRegistry.ConfigEntry? entry)) +#if NET10_0_OR_GREATER + if (amazonServiceClient.Config is ClientConfig clientConfig && + GeneratedClientRegistry.TryGetConfig(clientConfig.GetType(), out GeneratedClientRegistry.ConfigEntry? entry) && entry.PlainRegion) { - entry.Region(amazonServiceClient.Config, systemName); + SetPlainClientRegion(clientConfig, systemName); return; } - if (!System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported) + if (ReflectionFallback.IsSupported) { - throw new NotSupportedException($"Configuration type {amazonServiceClient.Config.GetType()} was not present when the application was compiled."); + SetLegacyClientRegion(amazonServiceClient, systemName); } -#endif + else if (amazonServiceClient.Config is ClientConfig plainConfig) + { + SetPlainClientRegion(plainConfig, systemName); + } +#else SetLegacyClientRegion(amazonServiceClient, systemName); +#endif + } + +#if NET10_0_OR_GREATER + private static void SetPlainClientRegion(ClientConfig config, string systemName) + { + try + { + config.RegionEndpoint = RegionEndpoint.GetBySystemName(systemName); + } + catch (Exception e) + { + throw new TargetInvocationException(e); + } } -#if NET8_0_OR_GREATER [RequiresUnreferencedCode("Dynamically loaded configurations require their original properties.")] #endif private static void SetLegacyClientRegion(AmazonServiceClient amazonServiceClient, string systemName) @@ -130,21 +158,24 @@ public bool SetForcePathStyle(ClientConfig clientConfig, bool value = true) return true; } #endif -#if NET8_0_OR_GREATER - if (GeneratedClientRegistry.TryGetConfig(clientConfig.GetType(), out GeneratedClientRegistry.ConfigEntry? entry)) +#if NET10_0_OR_GREATER + if (GeneratedClientRegistry.TryGetConfig(clientConfig.GetType(), out GeneratedClientRegistry.ConfigEntry? entry) && entry.Style != null) { return entry.Style(clientConfig, value); } - if (!System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported) + if (ReflectionFallback.IsSupported) { - throw new NotSupportedException($"Configuration type {clientConfig.GetType()} was not present when the application was compiled."); + return SetLegacyForcePathStyle(clientConfig, value); } -#endif + + return false; +#else return SetLegacyForcePathStyle(clientConfig, value); +#endif } -#if NET8_0_OR_GREATER +#if NET10_0_OR_GREATER [RequiresUnreferencedCode("Dynamically loaded configurations require their original properties.")] #endif private static bool SetLegacyForcePathStyle(ClientConfig clientConfig, bool value) @@ -161,7 +192,7 @@ private static bool SetLegacyForcePathStyle(ClientConfig clientConfig, bool valu return true; } -#if NET8_0_OR_GREATER +#if NET10_0_OR_GREATER [RequiresUnreferencedCode("Dynamically loaded clients require their original constructors.")] #endif private static ConstructorInfo FindConstructorWithCredentialsAndClientConfig(Type clientType) diff --git a/tests/LocalStack.Client.Generators.Tests/GeneratorTests.cs b/tests/LocalStack.Client.Generators.Tests/GeneratorTests.cs index b348331..9db6824 100644 --- a/tests/LocalStack.Client.Generators.Tests/GeneratorTests.cs +++ b/tests/LocalStack.Client.Generators.Tests/GeneratorTests.cs @@ -15,6 +15,104 @@ namespace LocalStack.Client.Generators.Tests; public class GeneratorTests { + [Theory] + [MemberData(nameof(RejectedSourceShapes))] + public void Generator_Should_Skip_Shapes_The_Compiler_Would_Reject(string shape, string clients, string configs, bool nullStyle) + { + ArgumentNullException.ThrowIfNull(clients); + ArgumentNullException.ThrowIfNull(configs); + (GeneratorDriverRunResult result, Compilation output) = Generate(EligibleFixture + + "namespace Review { public class Good : Amazon.S3.AmazonS3Client { } " + shape + " }"); + Assert.Empty(output.GetDiagnostics().Where(diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning && + diagnostic.Location.SourceTree?.FilePath.EndsWith("LocalStackGeneratedRegistry.g.cs", StringComparison.Ordinal) == true)); + Assert.Equal(clients.Split(',', StringSplitOptions.RemoveEmptyEntries), ReviewRegistrations(result, "Client")); + Assert.Equal(configs.Split(',', StringSplitOptions.RemoveEmptyEntries), ReviewRegistrations(result, "Config")); + if (nullStyle) + { + Assert.Contains("typeof(global::Review.BadConfig), null, true)", result.GeneratedTrees.Single().ToString(), StringComparison.Ordinal); + } + } + + public static IEnumerable RejectedSourceShapes() + { + const string config = "public class BadConfig : AbsentConfig { }"; + const string client = "public class Bad : Amazon.Runtime.AmazonServiceClient { BODY }"; + const string constructor = "public Bad(Amazon.Runtime.AWSCredentials credentials, BadConfig clientConfig) : base(credentials, clientConfig) { }"; + foreach (string modifier in new[] { "ref", "out", "in" }) + { + string body = string.Equals(modifier, "out", StringComparison.Ordinal) ? "clientConfig = new BadConfig();" : ""; + yield return new object[] { config + client.Replace("BODY", "public Bad(Amazon.Runtime.AWSCredentials credentials, " + modifier + + " BadConfig clientConfig) : base(credentials, new BadConfig()) { " + body + " }", StringComparison.Ordinal), "Good", "BadConfig", false }; + body = string.Equals(modifier, "out", StringComparison.Ordinal) ? "credentials = new Amazon.Runtime.AnonymousAWSCredentials();" : ""; + yield return new object[] { config + client.Replace("BODY", "public Bad(" + modifier + + " Amazon.Runtime.AWSCredentials credentials, BadConfig clientConfig) : base(new Amazon.Runtime.AnonymousAWSCredentials(), clientConfig) { " + body + " }", StringComparison.Ordinal), "Good", "BadConfig", false }; + } + + yield return new object[] { "public class BadConfig : AbsentConfig { public bool ForcePathStyle { get; init; } }", "Good", "BadConfig", true }; + yield return new object[] { config + client.Replace("BODY", "public required string Name { get; set; } " + constructor, StringComparison.Ordinal), "Good", "BadConfig", false }; + yield return new object[] { "public class BadConfig : AbsentConfig { public required string Name { get; set; } }" + + client.Replace("BODY", constructor, StringComparison.Ordinal), "Good", "", false }; + foreach (string attribute in new[] { "System.Obsolete", "System.Obsolete(\"old\", true)", "System.Diagnostics.CodeAnalysis.Experimental(\"REVIEW001\")" }) + { + string marked = "[" + attribute + "] "; + yield return new object[] { config + marked + client.Replace("BODY", constructor, StringComparison.Ordinal), "Good", "BadConfig", false }; + yield return new object[] { config + client.Replace("BODY", marked + constructor, StringComparison.Ordinal), "Good", "BadConfig", false }; + yield return new object[] { marked + config + client.Replace("BODY", constructor, StringComparison.Ordinal), "Good", "", false }; + yield return new object[] { "public class BadConfig : AbsentConfig { " + marked + "public BadConfig() { } }" + + client.Replace("BODY", constructor, StringComparison.Ordinal), "Good", "", false }; + yield return new object[] { "public class BadConfig : AbsentConfig { " + marked + "public bool ForcePathStyle { get; set; } }", "Good", "BadConfig", true }; + yield return new object[] { marked + "public class Outer { public class Bad : Amazon.S3.AmazonS3Client { } }", "Good", "", false }; + } + } + + private static string[] ReviewRegistrations(GeneratorDriverRunResult result, string kind) => result.GeneratedTrees.Single().ToString().Split('\n') + .Where(line => line.Contains("Register" + kind + "(null!, typeof(global::Review.", StringComparison.Ordinal)) + .Select(line => line.Split("typeof(global::Review.", StringSplitOptions.None)[1].Split(')')[0]).ToArray(); + + [Fact] + public void Generator_Should_Register_Only_Accessible_Concrete_NonGeneric_Clients() + { + (GeneratorDriverRunResult result, Compilation output) = Generate(""" + namespace Selection { + public class Zulu : Amazon.S3.AmazonS3Client { } + internal class Alpha : Amazon.S3.AmazonS3Client { } + public abstract class Abstract : Amazon.S3.AmazonS3Client { } + public class Generic : Amazon.S3.AmazonS3Client { } + public class Outer { public class Nested : Amazon.S3.AmazonS3Client { } } + public class Container { private class Hidden : Amazon.S3.AmazonS3Client { } } + } + """); + AssertNoCompilationErrors(output); + string[] registrations = result.GeneratedTrees.Single().ToString().Split('\n') + .Where(line => line.Contains("RegisterClient(null!", StringComparison.Ordinal) && line.Contains("typeof(global::Selection.", StringComparison.Ordinal)) + .Select(line => line.Split("typeof(global::Selection.", StringSplitOptions.None)[1].Split(')')[0]).ToArray(); + Assert.Collection(registrations, name => Assert.Equal("Alpha", name), name => Assert.Equal("Zulu", name)); + } + + [Theory] + [InlineData("public Exotic(Amazon.Runtime.BasicAWSCredentials credentials, StyleConfig clientConfig) : base(credentials, clientConfig) { }")] + [InlineData("public Exotic(object credentials, StyleConfig clientConfig) : base((Amazon.Runtime.AWSCredentials)credentials, clientConfig) { }")] + [InlineData("public Exotic(Amazon.Runtime.AWSCredentials credentials, Amazon.Runtime.IClientConfig clientConfig) : base(credentials, (Amazon.Runtime.ClientConfig)clientConfig) { }")] + [InlineData("public Exotic(Amazon.Runtime.AWSCredentials credentials, StyleConfig clientConfig, int extra = 0) : base(credentials, clientConfig) { }")] + [InlineData("public Exotic(params object[] arguments) : base((Amazon.Runtime.AWSCredentials)arguments[0], (Amazon.Runtime.ClientConfig)arguments[1]) { }")] + [InlineData("public Exotic(Amazon.Runtime.AWSCredentials credentials, Amazon.S3.AmazonS3Config clientConfig) : base(credentials, clientConfig) { }")] + public void Generator_Should_Leave_Nonstandard_Constructor_Shapes_To_The_Fallback(string constructor) + { + (_, Compilation output) = Generate(EligibleFixture + """ + public class Exotic : Amazon.Runtime.AmazonServiceClient { + public Exotic(Amazon.Runtime.AWSCredentials credentials, StyleConfig clientConfig) : base(credentials, clientConfig) { } + """ + constructor + "}"); + AssertNoCompilationErrors(output); + Assembly assembly = Load(output); + System.Runtime.CompilerServices.RuntimeHelpers.RunModuleConstructor(assembly.ManifestModule.ModuleHandle); + Type client = assembly.GetType("Exotic", true)!; + Type registry = typeof(Session).Assembly.GetType("LocalStack.Client.Utils.GeneratedClientRegistry", true)!; + object?[] args = { client, null }; + Assert.False((bool)registry.GetMethod("TryGetClient")!.Invoke(null, args)!); + // A positive companion defeats a generator that skips everything. + Assert.Equal("SQS", Operation>(Entry(assembly.GetType("MetadataClient", true)!, "TryGetClient"), "Metadata")().ServiceId); + } + [Fact] public void Generator_Should_Discover_Referenced_Sdk_Clients_Without_Registration_Code() { @@ -28,6 +126,10 @@ public void Generator_Should_Discover_Referenced_Sdk_Clients_Without_Registratio 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)); + var config = Operation>(Entry(typeof(AmazonS3Client), "TryGetClient"), "CreateConfig")(); + Assert.IsType(config); + Assert.Contains("global::Amazon.S3.AmazonS3Client? owner", result.GeneratedTrees.Single().ToString(), StringComparison.Ordinal); + Assert.Equal("S3", Operation>(Entry(typeof(AmazonS3Client), "TryGetClient"), "Metadata")().ServiceId); } [Fact] @@ -61,17 +163,17 @@ public void Generator_Should_Compile_When_Consumer_Declares_Global_Delegate_And_ } [Fact] - public void Generator_Should_Register_Private_Closed_Generic_Clients_Lazily_With_Their_Own_Metadata_And_Config() + public void Generator_Should_Leave_Private_Closed_Generic_Clients_To_The_Fallback() { (_, 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. + // Excluded shapes retain their behavior through the 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.False((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(); @@ -93,7 +195,7 @@ public void Generator_Should_Register_Private_Closed_Generic_Clients_Lazily_With [Fact] public void Generator_Should_Not_Evaluate_Client_Or_Config_Operations_During_Registration() { - (_, Compilation output) = Generate(PrivateGenericFixture); + (_, Compilation output) = Generate(EligibleFixture); Assembly assembly = Load(output); Type registry = assembly.GetType("LocalStack.Client.Generated.LocalStackGeneratedRegistry", throwOnError: true)!; MethodInfo initialize = registry.GetMethod("Initialize", BindingFlags.Static | BindingFlags.NonPublic)!; @@ -102,10 +204,458 @@ public void Generator_Should_Not_Evaluate_Client_Or_Config_Operations_During_Reg Type fixture = assembly.GetType("Fixture", throwOnError: true)!; Assert.Equal(0, fixture.GetField("MetadataCalls")!.GetValue(null)); Assert.Equal(0, fixture.GetField("ConfigCalls")!.GetValue(null)); + Type client = assembly.GetType("MetadataClient", throwOnError: true)!; + object entry = Entry(client, "TryGetClient"); + Assert.Equal("SQS", Operation>(entry, "Metadata")().ServiceId); + Assert.IsAssignableFrom(Operation>(entry, "CreateConfig")()); + Assert.Equal(1, fixture.GetField("MetadataCalls")!.GetValue(null)); + Assert.Equal(1, fixture.GetField("ConfigCalls")!.GetValue(null)); + } + + [Fact] + public void Generator_Should_Read_ServiceMetadata_Through_An_Exact_Static_Field_Accessor() + { + (GeneratorDriverRunResult result, Compilation output) = Generate(EligibleFixture); + string source = result.GeneratedTrees.Single().ToString(); + Assert.Contains("extern ref global::Metadata Field", source, StringComparison.Ordinal); + Assert.DoesNotContain("Type.GetType", source, StringComparison.Ordinal); + Assert.DoesNotContain("GetField", source, StringComparison.Ordinal); + Assembly assembly = Load(output); + Type client = assembly.GetType("MetadataClient", throwOnError: true)!; + object entry = Entry(client, "TryGetClient"); + object expected = client.GetProperty("MetadataValue")!.GetValue(null)!; + Assert.Same(expected, Operation>(entry, "Metadata")()); + Assert.Same(expected, new Utils.SessionReflection().ExtractServiceMetadata(client)); + } + + [Fact] + public void Generator_Should_Emit_Config_Creation_For_Zero_One_And_Many_Constructor_Matches() + { + (_, Compilation output) = Generate(EligibleFixture + """ + public class DerivedConfig : StyleConfig { } + public class Zero : Amazon.S3.AmazonS3Client { } + public class Two : Amazon.Runtime.AmazonServiceClient { + public Two(Amazon.Runtime.AWSCredentials credentials, StyleConfig clientConfig) : base(credentials, clientConfig) { } + public Two(Amazon.Runtime.AWSCredentials credentials, DerivedConfig clientConfig) : base(credentials, clientConfig) { } + } + public class ThrowingConfig : StyleConfig { + public static readonly System.Exception Failure = new System.InvalidOperationException("config"); + public ThrowingConfig() { throw Failure; } + } + public class ThrowingConfigClient : Amazon.Runtime.AmazonServiceClient { + public ThrowingConfigClient(Amazon.Runtime.AWSCredentials credentials, ThrowingConfig clientConfig) : base(credentials, clientConfig) { } + } + public abstract class AbstractConfig : StyleConfig { } + public class AbstractConfigClient : Amazon.Runtime.AmazonServiceClient { + public AbstractConfigClient(Amazon.Runtime.AWSCredentials credentials, AbstractConfig clientConfig) : base(credentials, clientConfig) { } + } + public class NoDefaultConfig : StyleConfig { public NoDefaultConfig(int value) { } } + public class NoDefaultClient : Amazon.Runtime.AmazonServiceClient { + public NoDefaultClient(Amazon.Runtime.AWSCredentials credentials, NoDefaultConfig clientConfig) : base(credentials, clientConfig) { } + } + """); + Assembly assembly = Load(output); + Func Config(string name) => Operation>(Entry(assembly.GetType(name, true)!, "TryGetClient"), "CreateConfig"); + Assert.Equal("Sequence contains no matching element", Assert.Throws(Config("Zero")).Message); + Assert.Equal("Sequence contains more than one matching element", Assert.Throws(Config("Two")).Message); + Assert.Equal(assembly.GetType("StyleConfig"), Config("MetadataClient")().GetType()); + TargetInvocationException error = Assert.Throws(Config("ThrowingConfigClient")); + Assert.Same(assembly.GetType("ThrowingConfig")!.GetField("Failure")!.GetValue(null), error.InnerException); + Assert.Throws(Config("AbstractConfigClient")); + Assert.Throws(Config("NoDefaultClient")); + } + + [Fact] + public void Generator_Should_Select_The_Constructor_With_The_Most_Derived_Compatible_Config() + { + (_, Compilation output) = Generate(EligibleFixture + """ + public class DerivedConfig : StyleConfig { } + public class DispatchClient : Amazon.Runtime.AmazonServiceClient { + public string Selected { get; } + public DispatchClient(Amazon.Runtime.AWSCredentials credentials, StyleConfig clientConfig) : base(credentials, clientConfig) { Selected = "base"; } + public DispatchClient(Amazon.Runtime.AWSCredentials credentials, DerivedConfig clientConfig) : base(credentials, clientConfig) { Selected = "derived"; } + } + """); + Assembly assembly = Load(output); + object entry = Entry(assembly.GetType("DispatchClient", true)!, "TryGetClient"); + var create = Operation>(entry, "Create"); + var credentials = new AnonymousAWSCredentials(); + var derived = (ClientConfig)Activator.CreateInstance(assembly.GetType("DerivedConfig", true)!)!; + using AmazonServiceClient client = create(credentials, derived)!; + Assert.Equal("derived", client.GetType().GetProperty("Selected")!.GetValue(client)); + Assert.Same(derived, client.Config); + var baseConfig = (ClientConfig)Activator.CreateInstance(assembly.GetType("StyleConfig", true)!)!; + using AmazonServiceClient baseClient = create(credentials, baseConfig)!; + Assert.Equal("base", baseClient.GetType().GetProperty("Selected")!.GetValue(baseClient)); + Assert.Null(create(credentials, new AmazonS3Config())); + } + + [Fact] + public void Generator_Should_Emit_ForcePathStyle_Setters_Only_For_Simple_Public_Properties() + { + (_, Compilation output) = Generate(EligibleFixture + """ + public class PrivateStyleConfig : AbsentConfig { public bool ForcePathStyle { get; private set; } } + public class HiddenStyleConfig : StyleConfig { public new bool ForcePathStyle { get; set; } } + public class WrongStyleConfig : AbsentConfig { public string ForcePathStyle { get; set; } = "initial"; } + public class ThrowingStyleConfig : AbsentConfig { + public static readonly System.Exception Failure = new System.InvalidOperationException("style"); + public bool ForcePathStyle { get => false; set => throw Failure; } + } + """); + Assembly assembly = Load(output); + object ConfigEntry(string name) => Entry(assembly.GetType(name, true)!, "TryGetConfig"); + Assert.Null(Operation(ConfigEntry("PrivateStyleConfig"), "Style")); + Assert.Null(Operation(ConfigEntry("HiddenStyleConfig"), "Style")); + Assert.Null(Operation(ConfigEntry("WrongStyleConfig"), "Style")); + var absent = (ClientConfig)Activator.CreateInstance(assembly.GetType("AbsentConfig")!)!; + Assert.False(Operation>(ConfigEntry("AbsentConfig"), "Style")(absent, true)); + var simple = (ClientConfig)Activator.CreateInstance(assembly.GetType("StyleConfig")!)!; + Assert.True(Operation>(ConfigEntry("StyleConfig"), "Style")(simple, true)); + Assert.True((bool)simple.GetType().GetProperty("ForcePathStyle")!.GetValue(simple)!); + var privateStyle = (ClientConfig)Activator.CreateInstance(assembly.GetType("PrivateStyleConfig")!)!; + Assert.True(new Utils.SessionReflection().SetForcePathStyle(privateStyle)); + Assert.True((bool)privateStyle.GetType().GetProperty("ForcePathStyle")!.GetValue(privateStyle)!); + var throwing = (ClientConfig)Activator.CreateInstance(assembly.GetType("ThrowingStyleConfig")!)!; + var style = Operation>(ConfigEntry("ThrowingStyleConfig"), "Style"); + TargetInvocationException error = Assert.Throws(() => style(throwing, true)); + Assert.Same(throwing.GetType().GetField("Failure")!.GetValue(null), error.InnerException); + } + + [Fact] + public void Generator_Should_Mark_RegionEndpoint_Plain_Only_When_It_Is_Not_Shadowed() + { + (_, Compilation output) = Generate(EligibleFixture + """ + public class ShadowConfig : StyleConfig { public new Amazon.RegionEndpoint RegionEndpoint { get; set; } } + """); + Assembly assembly = Load(output); + Type plain = assembly.GetType("StyleConfig", true)!; + Type shadow = assembly.GetType("ShadowConfig", true)!; + Assert.True(Operation(Entry(plain, "TryGetConfig"), "PlainRegion")); + Assert.False(Operation(Entry(shadow, "TryGetConfig"), "PlainRegion")); + var reflection = new Utils.SessionReflection(); + using var plainClient = new AmazonS3Client(new AnonymousAWSCredentials(), new AmazonS3Config { RegionEndpoint = Amazon.RegionEndpoint.USEast1 }); + reflection.SetClientRegion(plainClient, "eu-west-2"); + Assert.Equal("eu-west-2", plainClient.Config.RegionEndpoint.SystemName); + var config = (ClientConfig)Activator.CreateInstance(shadow)!; + var create = Operation>(Entry(assembly.GetType("MetadataClient", true)!, "TryGetClient"), "Create"); + using AmazonServiceClient client = create(new AnonymousAWSCredentials(), config)!; + reflection.SetClientRegion(client, "us-west-2"); + Assert.Equal(Amazon.RegionEndpoint.USWest2, shadow.GetProperty("RegionEndpoint")!.GetValue(config)); + Assert.Null(config.RegionEndpoint); + } + + [Fact] + public void Generator_Should_Emit_Code_That_Compiles_With_CSharp9() + { + var options = new CSharpParseOptions(LanguageVersion.CSharp9); + CSharpCompilation compilation = CreateCompilation(EligibleFixture); + compilation = compilation.ReplaceSyntaxTree(compilation.SyntaxTrees.Single(), CSharpSyntaxTree.ParseText(EligibleFixture, options)); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new[] { new AwsRegistryGenerator().AsSourceGenerator() }, parseOptions: options); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out Compilation output, out ImmutableArray diagnostics); + Assert.Empty(driver.GetRunResult().Diagnostics); + Assert.Empty(diagnostics); + AssertNoCompilationErrors(output); + Assembly assembly = Load(output); + Assert.Equal(assembly.GetType("StyleConfig"), Operation>(Entry(assembly.GetType("MetadataClient", true)!, "TryGetClient"), "CreateConfig")().GetType()); + } + + [Fact] + public void Generator_Should_Register_Clients_When_Consumer_Declares_Conflicting_Global_Aliases() + { + (_, Compilation output) = Generate(""" + global using Func = System.String; + global using Action = System.String; + global using UnsafeAccessorAttribute = System.ObsoleteAttribute; + global using ModuleInitializerAttribute = System.ObsoleteAttribute; + """ + EligibleFixture); + AssertNoCompilationErrors(output); + Assembly assembly = Load(output); + Assert.Equal("SQS", Operation>(Entry(assembly.GetType("MetadataClient", true)!, "TryGetClient"), "Metadata")().ServiceId); + } + + [Fact] + public void Generator_Should_Reuse_Reference_Discovery_When_References_Are_Unchanged() + { + const string externalSource = "namespace Review { public class External : Amazon.S3.AmazonS3Client { } public class Ordinary { } }"; + CSharpCompilation library = CreateCompilation(externalSource); + PortableExecutableReference reference = ImageReference(library); + CSharpCompilation compilation = CreateCompilation(EligibleFixture, reference); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new[] { new AwsRegistryGenerator().AsSourceGenerator() }, + driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true)); + driver = driver.RunGenerators(compilation); + Assert.Equal("External", Assert.Single(ReviewRegistrations(driver.GetRunResult(), "Client"))); + object cachedDiscovery = CachedReferenceDiscovery(reference); + Assert.Equal("Review.External", Assert.Single((IEnumerable)cachedDiscovery.GetType().GetProperty("Identities")!.GetValue(cachedDiscovery)!)); + string expected = driver.GetRunResult().GeneratedTrees.Single().ToString(); + compilation = compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText("internal class Unrelated { }")); + driver = driver.RunGenerators(compilation); + GeneratorDriverRunResult result = driver.GetRunResult(); + Assert.Equal(expected, result.GeneratedTrees.Single().ToString()); + Assert.Same(cachedDiscovery, CachedReferenceDiscovery(reference)); + Assert.Equal(IncrementalStepRunReason.Unchanged, Assert.Single(result.Results.Single().TrackedSteps["ReferenceContext"]).Outputs.Single().Reason); + Assert.All(result.Results.Single().TrackedSteps["ReferenceDiscovery"].SelectMany(step => step.Outputs), + output => Assert.Equal(IncrementalStepRunReason.Cached, output.Reason)); + PortableExecutableReference replacement = ImageReference(library.AddSyntaxTrees(CSharpSyntaxTree.ParseText( + "namespace Review { public class Added : Amazon.S3.AmazonS3Client { } }"))); + driver = driver.RunGenerators(compilation.ReplaceReference(reference, replacement)); + Assert.Collection(ReviewRegistrations(driver.GetRunResult(), "Client"), name => Assert.Equal("Added", name), name => Assert.Equal("External", name)); + Assert.Contains(driver.GetRunResult().Results.Single().TrackedSteps["ReferenceDiscovery"].SelectMany(step => step.Outputs), + output => output.Reason == IncrementalStepRunReason.New || output.Reason == IncrementalStepRunReason.Modified); } [Fact] - public void Generator_Should_Keep_Assembly_Identity_For_Referenced_Configs_With_The_Same_Full_Name() + public void Generator_Should_Discover_Clients_In_Assemblies_That_Reference_AwsSdk_Core_Transitively() + { + CSharpCompilation library = CreateCompilation("namespace Review { public class MyS3 : Amazon.S3.AmazonS3Client { } }"); + Assembly libraryAssembly = Load(library); + Assert.Contains(libraryAssembly.GetReferencedAssemblies(), name => string.Equals(name.Name, "AWSSDK.S3", StringComparison.Ordinal)); + Assert.DoesNotContain(libraryAssembly.GetReferencedAssemblies(), name => string.Equals(name.Name, "AWSSDK.Core", StringComparison.Ordinal)); + (GeneratorDriverRunResult result, Compilation output) = Generate("internal class Consumer { }", ImageReference(library)); + Assert.Equal("MyS3", Assert.Single(ReviewRegistrations(result, "Client"))); + Assembly consumer = Load(output); + System.Runtime.CompilerServices.RuntimeHelpers.RunModuleConstructor(consumer.ManifestModule.ModuleHandle); + Type client = libraryAssembly.GetType("Review.MyS3", true)!; + InvalidOperationException error = Assert.Throws(Operation>(Entry(client, "TryGetClient"), "Metadata")); + Assert.Equal("Invalid service type Review.MyS3", error.Message); + } + + [Fact] + public void Generator_Should_Keep_Other_Registrations_When_A_Referenced_Assembly_Is_Missing_At_Runtime() + { + CSharpCompilation library = CreateCompilation(""" + namespace Review { + public class MissingConfig : Amazon.S3.AmazonS3Config { } + public class Missing : Amazon.S3.AmazonS3Client { + public Missing(Amazon.Runtime.AWSCredentials credentials, MissingConfig clientConfig) : base(credentials, clientConfig) { } + } + } + """); + (GeneratorDriverRunResult result, Compilation output) = Generate(EligibleFixture, ImageReference(library)); + Assert.Equal("Missing", Assert.Single(ReviewRegistrations(result, "Client"))); + Assert.Equal("MissingConfig", Assert.Single(ReviewRegistrations(result, "Config"))); + using var stream = new MemoryStream(); + Assert.True(output.Emit(stream).Success); + stream.Position = 0; + var context = new AssemblyLoadContext("missing-client", isCollectible: true); + context.Resolving += (_, name) => throw new FileNotFoundException("Unavailable client library", name.FullName); + try + { + Assembly assembly = context.LoadFromStream(stream); + System.Runtime.CompilerServices.RuntimeHelpers.RunModuleConstructor(assembly.ManifestModule.ModuleHandle); + Type client = assembly.GetType("MetadataClient", true)!; + Assert.Equal("SQS", Operation>(Entry(client, "TryGetClient"), "Metadata")().ServiceId); + Assert.Equal(assembly.GetType("StyleConfig"), Operation>(Entry(client, "TryGetClient"), "CreateConfig")().GetType()); + } + finally + { + context.Unload(); + } + } + + [Theory] + [InlineData("other", "clientConfig", "StyleConfig")] + [InlineData("credentials", "other", "StyleConfig")] + [InlineData("credentials", "clientConfig", "Amazon.Runtime.ClientConfig")] + public void Generator_Should_Not_Select_Constructors_With_Wrong_Names_Or_The_ClientConfig_Base(string first, string second, string configType) + { + (GeneratorDriverRunResult result, Compilation output) = Generate(EligibleFixture + + "namespace Review { public class Wrong : Amazon.Runtime.AmazonServiceClient { public Wrong(Amazon.Runtime.AWSCredentials " + + first + ", " + configType + " " + second + ") : base(" + first + ", " + second + ") { } } }"); + Assert.Equal("Wrong", Assert.Single(ReviewRegistrations(result, "Client"))); + string registration = result.GeneratedTrees.Single().ToString().Split('\n').Single(line => line.Contains("RegisterClient(null!, typeof(global::Review.Wrong)", StringComparison.Ordinal)); + string configMethod = registration.Split(", Config", StringSplitOptions.None)[1].Split(',')[0]; + Assert.Contains("ClientConfig Config" + configMethod + "() { throw new global::System.InvalidOperationException(\"Sequence contains no matching element\")", + result.GeneratedTrees.Single().ToString(), StringComparison.Ordinal); + Assembly assembly = Load(output); + Assert.Equal("Sequence contains no matching element", Assert.Throws( + Operation>(Entry(assembly.GetType("Review.Wrong", true)!, "TryGetClient"), "CreateConfig")).Message); + Assert.Equal(assembly.GetType("StyleConfig"), Operation>(Entry(assembly.GetType("MetadataClient", true)!, "TryGetClient"), "CreateConfig")().GetType()); + } + + [Fact] + public void Generator_Should_Allow_Required_Members_When_The_Used_Constructor_Sets_Them() + { + (GeneratorDriverRunResult result, Compilation output) = Generate(EligibleFixture + """ + namespace Review { + public class RequiredConfig : AbsentConfig { + public required string Name { get; set; } + [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] + public RequiredConfig() { Name = "config"; } + } + public class RequiredClient : Amazon.Runtime.AmazonServiceClient { + public required string Name { get; set; } + [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] + public RequiredClient(Amazon.Runtime.AWSCredentials credentials, RequiredConfig clientConfig) : base(credentials, clientConfig) { Name = "client"; } + } + } + """); + Assert.Equal("RequiredClient", Assert.Single(ReviewRegistrations(result, "Client"))); + Assert.Equal("RequiredConfig", Assert.Single(ReviewRegistrations(result, "Config"))); + Assembly assembly = Load(output); + object entry = Entry(assembly.GetType("Review.RequiredClient", true)!, "TryGetClient"); + ClientConfig config = Operation>(entry, "CreateConfig")(); + Assert.Equal("config", config.GetType().GetProperty("Name")!.GetValue(config)); + using AmazonServiceClient client = Operation>(entry, "Create")(new AnonymousAWSCredentials(), config)!; + Assert.Equal("client", client.GetType().GetProperty("Name")!.GetValue(client)); + Assert.Same(config, client.Config); + } + + [Theory] + [InlineData("public static bool ForcePathStyle { get; set; }")] + [InlineData("[System.Runtime.CompilerServices.IndexerName(\"ForcePathStyle\")] public bool this[int index] { get => false; set { } }")] + public void Generator_Should_Leave_Static_And_Indexed_ForcePathStyle_Members_To_The_Fallback(string property) + { + (GeneratorDriverRunResult result, Compilation output) = Generate(EligibleFixture + + "namespace Review { public class BadConfig : AbsentConfig { " + property + " } }"); + Assert.Equal("BadConfig", Assert.Single(ReviewRegistrations(result, "Config"))); + Assembly assembly = Load(output); + Assert.Null(Operation(Entry(assembly.GetType("Review.BadConfig", true)!, "TryGetConfig"), "Style")); + var config = (ClientConfig)Activator.CreateInstance(assembly.GetType("StyleConfig", true)!)!; + Assert.True(Operation>(Entry(config.GetType(), "TryGetConfig"), "Style")(config, true)); + Assert.True((bool)config.GetType().GetProperty("ForcePathStyle")!.GetValue(config)!); + } + + [Fact] + public void Generator_Should_Skip_Clients_With_Inaccessible_Config_Or_Field_Types() + { + (GeneratorDriverRunResult result, Compilation output) = Generate(EligibleFixture + """ + namespace Review { + public class Good : Amazon.S3.AmazonS3Client { } + public class PrivateField : Amazon.S3.AmazonS3Client { + private class Metadata { } + private static readonly Metadata serviceMetadata = new Metadata(); + } + public class PrivateConfig : Amazon.Runtime.AmazonServiceClient { + private class Config : AbsentConfig { } + public PrivateConfig(Amazon.Runtime.AWSCredentials credentials, Config clientConfig) : base(credentials, clientConfig) { } + } + } + """); + // An incomplete consumer can expose an inaccessible config in a public signature. + // The generator must still omit it rather than add more compiler errors. + Assert.Equal("CS0051", Assert.Single(output.GetDiagnostics().Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)).Id); + Assert.Equal("Good", Assert.Single(ReviewRegistrations(result, "Client"))); + Assert.Empty(ReviewRegistrations(result, "Config")); + Assert.DoesNotContain(output.GetDiagnostics(), diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning && + diagnostic.Location.SourceTree?.FilePath.EndsWith("LocalStackGeneratedRegistry.g.cs", StringComparison.Ordinal) == true); + } + + [Fact] + public void Generator_Should_Exclude_Referenced_Protected_Private_And_Generic_Clients() + { + CSharpCompilation library = CreateCompilation(""" + namespace Review { + public class Good : Amazon.S3.AmazonS3Client { } + public class Generic : Amazon.S3.AmazonS3Client { } + public class Outer { + protected class Protected : Amazon.S3.AmazonS3Client { } + private class Private : Amazon.S3.AmazonS3Client { } + public class Generic : Amazon.S3.AmazonS3Client { } + } + } + """); + Assembly libraryAssembly = Load(library); + (GeneratorDriverRunResult result, Compilation output) = Generate("internal class Consumer { }", ImageReference(library)); + Assert.Equal("Good", Assert.Single(ReviewRegistrations(result, "Client"))); + Assembly consumer = Load(output); + System.Runtime.CompilerServices.RuntimeHelpers.RunModuleConstructor(consumer.ManifestModule.ModuleHandle); + InvalidOperationException error = Assert.Throws(Operation>( + Entry(libraryAssembly.GetType("Review.Good", true)!, "TryGetClient"), "Metadata")); + Assert.Equal("Invalid service type Review.Good", error.Message); + } + + private static PortableExecutableReference ImageReference(Compilation compilation) + { + using var stream = new MemoryStream(); + Assert.True(compilation.Emit(stream).Success); + return MetadataReference.CreateFromImage(stream.ToArray()); + } + + [Fact] + public void Generator_Should_Not_Abort_Initialization_When_The_Registration_Bridge_Is_Missing() + { + Compilation core = CreateCompilation("namespace LocalStack.Client { public class Session { } }"); + core = core.RemoveReferences(core.References.Where(reference => string.Equals(reference.Display, typeof(Session).Assembly.Location, StringComparison.Ordinal))); + Load(core); + using var stream = new MemoryStream(); + Assert.True(core.Emit(stream).Success); + CSharpCompilation compilation = CreateCompilation(EligibleFixture); + compilation = compilation.RemoveReferences(compilation.References.Where(reference => string.Equals(reference.Display, typeof(Session).Assembly.Location, StringComparison.Ordinal))) + .AddReferences(MetadataReference.CreateFromImage(stream.ToArray())); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new AwsRegistryGenerator().AsSourceGenerator()); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out Compilation output, out _); + Assert.Empty(driver.GetRunResult().Diagnostics); + AssertNoCompilationErrors(output); + Assembly assembly = Load(output); + System.Runtime.CompilerServices.RuntimeHelpers.RunModuleConstructor(assembly.ManifestModule.ModuleHandle); + Assert.Equal(0, assembly.GetType("Fixture", true)!.GetField("MetadataCalls")!.GetValue(null)); + Assert.Equal(0, assembly.GetType("Fixture", true)!.GetField("ConfigCalls")!.GetValue(null)); + } + + [Fact] + public void Generator_Should_Exclude_Referenced_Clients_With_Ambiguous_Names() + { + const string librarySource = "namespace Review { public class Collision : Amazon.S3.AmazonS3Client { } }"; + (GeneratorDriverRunResult result, Compilation output) = Generate(EligibleFixture, + ImageReference(CreateCompilation(librarySource)), ImageReference(CreateCompilation(librarySource))); + AssertNoCompilationErrors(output); + Assert.Empty(ReviewRegistrations(result, "Client")); + Assembly assembly = Load(output); + Assert.Equal("SQS", Operation>( + Entry(assembly.GetType("MetadataClient", true)!, "TryGetClient"), "Metadata")().ServiceId); + } + + private static object CachedReferenceDiscovery(MetadataReference reference) + { + object cache = typeof(AwsRegistryGenerator).GetField("References", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; + object?[] arguments = { reference, null }; + Assert.True((bool)cache.GetType().GetMethod("TryGetValue")!.Invoke(cache, arguments)!); + return Assert.Single(((System.Collections.IDictionary)arguments[1]!).Values.Cast()); + } + + private static object Entry(Type type, string lookupName) + { + System.Runtime.CompilerServices.RuntimeHelpers.RunModuleConstructor(type.Assembly.ManifestModule.ModuleHandle); + Type registry = typeof(Session).Assembly.GetType("LocalStack.Client.Utils.GeneratedClientRegistry", true)!; + object?[] args = { type, null }; + Assert.True((bool)registry.GetMethod(lookupName)!.Invoke(null, args)!, type.FullName); + return args[1]!; + } + + private static T Operation(object entry, string name) + { + PropertyInfo? property = entry.GetType().GetProperty(name); + Assert.NotNull(property); + return (T)property.GetValue(entry)!; + } + + private const string EligibleFixture = """ + public static class Fixture { public static int MetadataCalls; public static int ConfigCalls; } + public class AbsentConfig : Amazon.Runtime.ClientConfig { + public AbsentConfig() : base(new Amazon.Runtime.Internal.DefaultConfigurationProvider(Amazon.S3.AmazonS3DefaultConfiguration.GetAllConfigurations())) { ServiceURL = "http://localhost"; } + public override string RegionEndpointServiceName => "fixture"; + public override Amazon.Runtime.Endpoints.Endpoint DetermineServiceOperationEndpoint(Amazon.Runtime.Endpoints.ServiceOperationEndpointParameters parameters) => new Amazon.Runtime.Endpoints.Endpoint(ServiceURL); + public override string ServiceVersion => "1"; + public override string UserAgent => "fixture"; + } + public class StyleConfig : AbsentConfig { + public StyleConfig() { Fixture.ConfigCalls++; } + public bool ForcePathStyle { get; set; } + } + public class MetadataClient : Amazon.Runtime.AmazonServiceClient { + private static readonly Metadata serviceMetadata = new Metadata(); + public static Amazon.Runtime.Internal.IServiceMetadata MetadataValue => serviceMetadata; + public MetadataClient(Amazon.Runtime.AWSCredentials credentials, StyleConfig clientConfig) : base(credentials, clientConfig) { } + } + public class Metadata : Amazon.Runtime.Internal.IServiceMetadata { + public Metadata() { Fixture.MetadataCalls++; } + public string ServiceId => "SQS"; + public System.Collections.Generic.IDictionary OperationNameMapping { get; } = new System.Collections.Generic.Dictionary(); + } + """; + + [Fact] + public void Generator_Should_Leave_Same_Name_Configs_From_Different_Assemblies_To_The_Fallback() { const string configSource = "namespace Shared { public class Config : Amazon.S3.AmazonS3Config { } }"; Compilation first = CreateCompilation(configSource); @@ -138,7 +688,7 @@ public static class Consumer { foreach (Type type in new[] { firstType, secondType }) { object?[] lookup = { type, null }; - Assert.True((bool)registry.GetMethod("TryGetConfig")!.Invoke(null, lookup)!); + Assert.False((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); @@ -146,7 +696,7 @@ public static class Consumer { } [Fact] - public void Generator_Should_Discover_Inaccessible_Referenced_Configs_Without_Consumer_Syntax() + public void Generator_Should_Leave_Inaccessible_Referenced_Configs_To_The_Fallback() { Compilation library = CreateCompilation(""" public static class Factory { @@ -163,13 +713,13 @@ private sealed class PrivateConfig : Amazon.S3.AmazonS3Config { } 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.False((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() + public void Generator_Should_Leave_Closed_Generic_Signature_Types_To_The_Fallback() { Compilation library = CreateCompilation(ReferencedSignatureFixture); Assembly libraryAssembly = Load(library); @@ -190,13 +740,13 @@ public void Generator_Should_Register_Closed_Signature_Types_Lazily_Without_Cons foreach (Type argument in arguments) { object?[] lookup = { clientDefinition.MakeGenericType(argument), null }; - Assert.True((bool)registry.GetMethod("TryGetClient")!.Invoke(null, lookup)!); + Assert.False((bool)registry.GetMethod("TryGetClient")!.Invoke(null, lookup)!); object?[] configLookup = { configDefinition.MakeGenericType(argument), null }; - Assert.True((bool)registry.GetMethod("TryGetConfig")!.Invoke(null, configLookup)!); + Assert.False((bool)registry.GetMethod("TryGetConfig")!.Invoke(null, configLookup)!); } object?[] signatureConfigLookup = { configDefinition.MakeGenericType(typeof(decimal)), null }; - Assert.True((bool)registry.GetMethod("TryGetConfig")!.Invoke(null, signatureConfigLookup)!); + Assert.False((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(); @@ -231,7 +781,7 @@ public static class Consumer { public static System.Type ClientType => typeof(Or 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.False((bool)registry.GetMethod("TryGetClient")!.Invoke(null, lookup)!); Assert.IsType(new Utils.SessionReflection().CreateClientConfig(type)); Assert.Throws(() => new Utils.SessionReflection().ExtractServiceMetadata(type)); } @@ -239,6 +789,7 @@ public static class Consumer { public static System.Type ClientType => typeof(Or private static Assembly Load(Compilation compilation) { using var stream = new MemoryStream(); + AssertNoCompilationErrors(compilation); Assert.True(compilation.Emit(stream).Success); stream.Position = 0; return AssemblyLoadContext.Default.LoadFromStream(stream); diff --git a/tests/LocalStack.Client.Integration.Tests/CreateClientByInterfaceTests.cs b/tests/LocalStack.Client.Integration.Tests/CreateClientByInterfaceTests.cs index 44b0409..0ba0500 100644 --- a/tests/LocalStack.Client.Integration.Tests/CreateClientByInterfaceTests.cs +++ b/tests/LocalStack.Client.Integration.Tests/CreateClientByInterfaceTests.cs @@ -23,6 +23,31 @@ public void SetForcePathStyle_Should_Set_S3_Config_And_Return_False_For_Sqs_Conf Assert.False(reflection.SetForcePathStyle(new AmazonSQSConfig())); } + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public void CreateClientByInterface_Should_Use_Generated_Operations_For_Sdk_Interface_Types(bool s3, bool useServiceUrl) + { + System.Type implementation = s3 ? typeof(AmazonS3Client) : typeof(AmazonSQSClient); +#if NET10_0_OR_GREATER + Assert.True(Utils.GeneratedClientRegistry.TryGetClient(implementation, out Utils.GeneratedClientRegistry.ClientEntry? entry)); + Assert.Equal(s3 ? "S3" : "SQS", entry.Metadata().ServiceId); + Assert.True(Utils.GeneratedClientRegistry.TryGetConfig(s3 ? typeof(AmazonS3Config) : typeof(AmazonSQSConfig), out Utils.GeneratedClientRegistry.ConfigEntry? configEntry)); + Assert.True(configEntry.PlainRegion); + Assert.NotNull(configEntry.Style); +#endif + using AmazonServiceClient client = Session.CreateClientByInterface(s3 ? typeof(IAmazonS3) : typeof(IAmazonSQS), useServiceUrl); + Assert.Equal(implementation, client.GetType()); + AssertAmazonClient.AssertClientConfiguration(client); + Assert.Equal(useServiceUrl, client.Config.ServiceURL != null); + if (s3) + { + Assert.True(((AmazonS3Config)client.Config).ForcePathStyle); + } + } + [Fact] public void Should_Able_To_Create_AmazonAPIGatewayClient() { @@ -1147,4 +1172,4 @@ public void Should_Able_To_Create_AmazonVerifiedPermissions() Assert.NotNull(amazonVerifiedPermissions); AssertAmazonClient.AssertClientConfiguration(amazonVerifiedPermissions); } -} \ No newline at end of file +} diff --git a/tests/LocalStack.Client.Tests/SessionTests/SessionReflectionTests.cs b/tests/LocalStack.Client.Tests/SessionTests/SessionReflectionTests.cs index 90778e7..5cbffb3 100644 --- a/tests/LocalStack.Client.Tests/SessionTests/SessionReflectionTests.cs +++ b/tests/LocalStack.Client.Tests/SessionTests/SessionReflectionTests.cs @@ -2,6 +2,74 @@ public class SessionReflectionTests { +#if NET10_0_OR_GREATER + [Fact] + public void SessionReflection_Should_Return_Private_Field_Metadata_And_Apply_Config_Effects_For_Registered_Clients() + { + Type clientType = typeof(MetadataClient); + Assert.True(GeneratedClientRegistry.TryGetClient(clientType, out GeneratedClientRegistry.ClientEntry? entry)); + Assert.Same(MetadataClient.MetadataValue, entry.Metadata()); + var reflection = new SessionReflection(); + Assert.Same(MetadataClient.MetadataValue, reflection.ExtractServiceMetadata()); + Assert.Same(MetadataClient.MetadataValue, reflection.ExtractServiceMetadata(clientType)); + var config = Assert.IsType(reflection.CreateClientConfig()); + Assert.IsType(reflection.CreateClientConfig(clientType)); + Assert.True(GeneratedClientRegistry.TryGetConfig(typeof(StyleConfig), out GeneratedClientRegistry.ConfigEntry? configEntry)); + Assert.NotNull(configEntry.Style); + Assert.True(configEntry.PlainRegion); + Assert.False(config.ForcePathStyle); + Assert.True(reflection.SetForcePathStyle(config)); + Assert.True(config.ForcePathStyle); + var credentials = new BasicAWSCredentials("fixture-id", "fixture-secret"); + using AmazonServiceClient client = Assert.IsType(entry.Create(credentials, config)); + Assert.Same(config, client.Config); + reflection.SetClientRegion(client, "eu-west-2"); + Assert.Equal("eu-west-2", config.RegionEndpoint.SystemName); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CreateClientByInterface_Should_Keep_Injected_Config_Identity_And_Error_Order_With_Generated_Operations(bool byInterface) + { + Assert.True(GeneratedClientRegistry.TryGetClient(typeof(MockAmazonServiceClient), out GeneratedClientRegistry.ClientEntry? entry)); + Assert.NotNull(entry.CreateConfig); + var config = new StyleConfig(); + var calls = new List(); + var metadata = new Mock(); + metadata.SetupGet(value => value.ServiceId).Returns("SQS"); + var reflection = new Mock(MockBehavior.Strict); + Type expectedType = typeof(MockAmazonServiceClient); + reflection.Setup(helper => helper.ExtractServiceMetadata(expectedType)) + .Returns(() => { calls.Add("metadata"); return metadata.Object; }); + reflection.Setup(helper => helper.CreateClientConfig(expectedType)) + .Returns(() => { calls.Add("config"); return config; }); + reflection.Setup(helper => helper.SetForcePathStyle(config, true)) + .Returns(() => { calls.Add("style"); config.ForcePathStyle = true; return true; }); + var session = new Session(new SessionOptions(regionName: "eu-west-2"), new Config(new ConfigOptions()), reflection.Object); + using AmazonServiceClient client = byInterface + ? session.CreateClientByInterface() + : session.CreateClientByImplementation(); + Assert.IsType(client); + Assert.Same(config, client.Config); + Assert.True(config.ForcePathStyle); + Assert.Equal("eu-west-2", client.Config.RegionEndpoint.SystemName); + Assert.Collection(calls, call => Assert.Equal("metadata", call), call => Assert.Equal("config", call), call => Assert.Equal("style", call)); + } + + internal sealed class StyleConfig : MockClientConfig + { + public bool ForcePathStyle { get; set; } + } + + internal sealed class MetadataClient : AmazonServiceClient + { + private static readonly IServiceMetadata serviceMetadata = new MockServiceMetadata(); + public static IServiceMetadata MetadataValue => serviceMetadata; + public MetadataClient(AWSCredentials credentials, StyleConfig clientConfig) : base(credentials, clientConfig) { } + } +#endif + [Fact] public void ExtractServiceMetadata_Should_Throw_InvalidOperationException_If_Given_Generic_Service_Client_Type_Has_Not_Service_Metadata_Field() { @@ -77,4 +145,4 @@ public void SetClientRegion_Should_Set_RegionEndpoint_Of_The_Given_Client_By_Sys Assert.NotNull(mockAmazonServiceClient.Config.RegionEndpoint); Assert.Equal(RegionEndpoint.GetBySystemName(systemName), mockAmazonServiceClient.Config.RegionEndpoint); } -} \ No newline at end of file +}