diff --git a/src/common/Ark.Tools.Core/ArkTypeConverter.cs b/src/common/Ark.Tools.Core/ArkTypeConverter.cs new file mode 100644 index 000000000..e887d8e6e --- /dev/null +++ b/src/common/Ark.Tools.Core/ArkTypeConverter.cs @@ -0,0 +1,166 @@ +// Copyright (C) 2024 Ark Energy S.r.l. All rights reserved. +// Licensed under the MIT License. See LICENSE file for license information. + +using System.ComponentModel; + +namespace Ark.Tools.Core; + +/// +/// Provides string-to-value conversion with per-type caching. +/// +public static class ArkTypeConverter +{ + /// + /// Tries to convert to using a cached + /// for the underlying type. + /// + /// The target type, including Nullable<U> variants. + /// The string value to convert. May be . + /// + /// When this method returns , contains the converted value; + /// otherwise the default value for . + /// + /// + /// if the conversion succeeded; + /// if is and + /// is a non-nullable value type, or if the conversion failed. + /// + [RequiresUnreferencedCode("TypeDescriptor.GetConverter is not trim-safe. Ensure T and its TypeConverter are preserved.")] + public static bool TryConvert<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(string? input, out T value) + { + if (input is null) + { + value = default!; + // ponytail: relies on default(T) == null for reference types and Nullable; + // for non-nullable value types default(T) != null, so this correctly returns false. + return default(T) is null; + } + + try + { + value = ConverterCache.Convert(input); + return true; + } + catch (Exception ex) when (ex is FormatException + or NotSupportedException + or InvalidCastException + or OverflowException + or ArgumentException) + { + value = default!; + return false; + } + } + + /// + /// Tries to convert to using a cached + /// obtained via TypeDescriptor.GetConverterFromRegisteredType. + /// + /// + /// + /// This method is trim-safe on .NET 9 and later, where it calls + /// TypeDescriptor.GetConverterFromRegisteredType which only considers explicitly + /// registered converters and does not perform reflection-based discovery. + /// + /// + /// On .NET 8 it falls back to TypeDescriptor.GetConverter and suppresses the trim + /// warning; callers must ensure that all required registrations are + /// in place at application start (e.g. via TypeDescriptor.AddAttributes or NodaTime's + /// TypeDescriptor.RegisterType calls). + /// + /// + /// The target type, including Nullable<U> variants. + /// The string value to convert. May be . + /// + /// When this method returns , contains the converted value; + /// otherwise the default value for . + /// + /// + /// if the conversion succeeded; + /// if is and + /// is a non-nullable value type, or if the conversion failed. + /// + public static bool TryConvertSafe(string? input, out T value) + { + if (input is null) + { + value = default!; + // ponytail: same null-handling contract as TryConvert. + return default(T) is null; + } + + try + { + value = ConverterCacheSafe.Convert(input); + return true; + } + catch (Exception ex) when (ex is FormatException + or NotSupportedException + or InvalidCastException + or OverflowException + or ArgumentException) + { + value = default!; + return false; + } + } + + [RequiresUnreferencedCode("TypeDescriptor.GetConverter is not trim-safe. Ensure T and its TypeConverter are preserved.")] + private static class ConverterCache<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T> + { + private static readonly Func _convert = Build(); + + private static Func Build() + { + var type = typeof(T); + var underlying = Nullable.GetUnderlyingType(type) ?? type; + + if (underlying == typeof(string)) + return static input => (T)(object)input; + + var converter = TypeDescriptor.GetConverter(underlying); + return input => + { + var obj = converter.ConvertFromString(null, CultureInfo.InvariantCulture, input); + return (T)obj!; + }; + } + + public static T Convert(string input) => _convert(input); + } + + private static class ConverterCacheSafe + { + private static readonly Func _convert = Build(); + + private static Func Build() + { + var type = typeof(T); + var underlying = Nullable.GetUnderlyingType(type) ?? type; + + if (underlying == typeof(string)) + return static input => (T)(object)input; + +#if NET9_0_OR_GREATER + var converter = TypeDescriptor.GetConverterFromRegisteredType(underlying); +#else + var converter = GetConverterNet8(underlying); +#endif + return input => + { + var obj = converter.ConvertFromString(null, CultureInfo.InvariantCulture, input); + return (T)obj!; + }; + } + +#if !NET9_0_OR_GREATER + [UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", + Justification = "Callers of TryConvertSafe ensure TypeConverter registrations are in place at startup. On .NET 9+ use GetConverterFromRegisteredType instead.")] + [UnconditionalSuppressMessage("Trimming", "IL2067:DynamicallyAccessedMembers", + Justification = "Callers of TryConvertSafe ensure TypeConverter registrations are in place at startup. On .NET 9+ use GetConverterFromRegisteredType instead.")] + private static TypeConverter GetConverterNet8(Type underlying) => TypeDescriptor.GetConverter(underlying); +#endif + + public static T Convert(string input) => _convert(input); + } +} diff --git a/src/mediator-framework/Ark.Tools.MediatorFramework.AzureFunctions.Generators/AzureFunctionsEndpointGenerator.cs b/src/mediator-framework/Ark.Tools.MediatorFramework.AzureFunctions.Generators/AzureFunctionsEndpointGenerator.cs index 025add788..a991ff865 100644 --- a/src/mediator-framework/Ark.Tools.MediatorFramework.AzureFunctions.Generators/AzureFunctionsEndpointGenerator.cs +++ b/src/mediator-framework/Ark.Tools.MediatorFramework.AzureFunctions.Generators/AzureFunctionsEndpointGenerator.cs @@ -7,6 +7,7 @@ using System.Globalization; using System.Linq; using System.Text; +using System.Text.RegularExpressions; using Microsoft.CodeAnalysis; @@ -16,9 +17,17 @@ namespace Ark.MediatorFramework.AzureFunctions.Generators; [Generator(LanguageNames.CSharp)] public sealed class AzureFunctionsEndpointGenerator : IIncrementalGenerator { + // ponytail: [GeneratedRegex] is not available for netstandard2.0 targets; static field compiles and caches once. + private static readonly Regex _routeParamRegex = new Regex(@"\{(?[^}:]+)(?::[^}]+)?\}", RegexOptions.ExplicitCapture, TimeSpan.FromSeconds(1)); private const string HostAttribute = "Ark.MediatorFramework.HttpHostAttribute"; private const string EndpointAttribute = "Ark.MediatorFramework.HttpEndpointAttribute"; private const string VersioningAttribute = "Ark.MediatorFramework.VersioningAttribute"; + private const string HttpRouteAttribute = "Ark.MediatorFramework.HttpRouteAttribute"; + private const string HttpQueryAttribute = "Ark.MediatorFramework.HttpQueryAttribute"; + private const string ServerSetAttribute = "Ark.MediatorFramework.ServerSetAttribute"; + private const string SolidRequest = "global::Ark.Tools.Solid.IRequest"; + private const string SolidQuery = "global::Ark.Tools.Solid.IQuery"; + private const string SolidCommand = "global::Ark.Tools.Solid.ICommand"; private static readonly DiagnosticDescriptor MessagePackNotSupported = new( "ARKMF030", @@ -168,25 +177,119 @@ private static void Emit( source.AppendLine("{"); foreach (var endpoint in valid) { - source.Append(" [global::Microsoft.Azure.Functions.Worker.Function(\"") - .Append(endpoint.FunctionName).AppendLine("\")]"); - source.Append(" public static async global::System.Threading.Tasks.Task ") - .Append(endpoint.FunctionName).AppendLine("("); - source.Append(" [global::Microsoft.Azure.Functions.Worker.HttpTrigger(") - .Append("global::Microsoft.Azure.Functions.Worker.AuthorizationLevel.Anonymous, \"") - .Append(endpoint.Verb.ToLowerInvariant()).Append("\", Route = \"") - .Append(Escape(endpoint.Route)).AppendLine("\")]"); - source.AppendLine(" global::Microsoft.AspNetCore.Http.HttpRequest request,"); - source.AppendLine(" global::System.Threading.CancellationToken cancellationToken)"); - source.AppendLine(" {"); - source.Append(" return await global::Ark.MediatorFramework.AzureFunctions.ArkAzureFunctionsInvocation.InvokeAsync<") - .Append(endpoint.FullyQualifiedType).AppendLine(">(request, cancellationToken).ConfigureAwait(false);"); - source.AppendLine(" }"); + EmitFunction(source, endpoint); } source.AppendLine("}"); context.AddSource("ArkGeneratedFunctions.g.cs", source.ToString()); } + private static void EmitFunction(StringBuilder source, Endpoint endpoint) + { + var hasBody = endpoint.Verb is "POST" or "PUT" or "PATCH"; + var routeProperties = endpoint.Properties.Where(p => p.IsRoute && !p.IsServerSet).ToArray(); + var queryProperties = endpoint.Properties.Where(p => p.IsQuery && !p.IsServerSet).ToArray(); + var serverSetProperties = endpoint.Properties.Where(p => p.IsServerSet).ToArray(); + + source.Append(" [global::Microsoft.Azure.Functions.Worker.Function(\"") + .Append(endpoint.FunctionName).AppendLine("\")]"); + source.Append(" public static async global::System.Threading.Tasks.Task ") + .Append(endpoint.FunctionName).AppendLine("("); + source.Append(" [global::Microsoft.Azure.Functions.Worker.HttpTrigger(") + .Append("global::Microsoft.Azure.Functions.Worker.AuthorizationLevel.Anonymous, \"") + .Append(endpoint.Verb.ToLowerInvariant()).Append("\", Route = \"") + .Append(Escape(endpoint.Route)).AppendLine("\")]"); + source.AppendLine(" global::Microsoft.AspNetCore.Http.HttpRequest request,"); + source.AppendLine(" global::System.Threading.CancellationToken cancellationToken)"); + source.AppendLine(" {"); + + // Body or default-instance binding + if (hasBody) + { + source.Append(" ").Append(endpoint.FullyQualifiedType).AppendLine("? _bodyNullable;"); + source.AppendLine(" try"); + source.AppendLine(" {"); + source.Append(" _bodyNullable = await request.ReadFromJsonAsync<").Append(endpoint.FullyQualifiedType).AppendLine(">(cancellationToken).ConfigureAwait(false);"); + source.AppendLine(" }"); + source.AppendLine(" catch (global::System.Text.Json.JsonException ex)"); + source.AppendLine(" {"); + source.AppendLine(" return global::Microsoft.AspNetCore.Http.Results.Problem(statusCode: 400, title: \"INVALID_REQUEST_BODY\", detail: ex.Message);"); + source.AppendLine(" }"); + source.AppendLine(" if (_bodyNullable is null)"); + source.AppendLine(" return global::Microsoft.AspNetCore.Http.Results.Problem(statusCode: 400, title: \"INVALID_REQUEST_BODY\", detail: \"Request body is missing or could not be deserialized.\");"); + source.AppendLine(" var body = _bodyNullable;"); + } + else + { + source.Append(" var body = new ").Append(endpoint.FullyQualifiedType).AppendLine("();"); + } + + // Route value binding (per-property, no runtime reflection) + foreach (var prop in routeProperties) + { + if (prop.IsString) + { + source.Append(" body.").Append(prop.Name).Append(" = request.RouteValues[").Append(Literal(prop.BindingName)).AppendLine("]?.ToString();"); + } + else + { + var varName = "_route_" + prop.Name; + source.Append(" if (!global::Ark.Tools.Core.ArkTypeConverter.TryConvertSafe<").Append(prop.TypeFullName).Append(">(request.RouteValues[").Append(Literal(prop.BindingName)).Append("]?.ToString(), out var ").Append(varName).AppendLine("))"); + source.Append(" return global::Microsoft.AspNetCore.Http.Results.Problem(statusCode: 400, title: \"BINDING_FAILURE\", detail: \"Route value '").Append(prop.BindingName).Append("' could not be bound to type '").Append(prop.TypeFullName).AppendLine("'.\");"); + source.Append(" body.").Append(prop.Name).Append(" = ").Append(varName).AppendLine(";"); + } + } + + // Query string binding (per-property, no runtime reflection) + foreach (var prop in queryProperties) + { + source.Append(" if (request.Query.TryGetValue(").Append(Literal(prop.Name)).Append(", out var _qs_").Append(prop.Name).AppendLine("))"); + source.AppendLine(" {"); + if (prop.IsString) + { + source.Append(" body.").Append(prop.Name).Append(" = (string?)_qs_").Append(prop.Name).AppendLine(";"); + } + else + { + var varName = "_query_" + prop.Name; + source.Append(" if (!global::Ark.Tools.Core.ArkTypeConverter.TryConvertSafe<").Append(prop.TypeFullName).Append(">(_qs_").Append(prop.Name).Append(", out var ").Append(varName).AppendLine("))"); + source.Append(" return global::Microsoft.AspNetCore.Http.Results.Problem(statusCode: 400, title: \"BINDING_FAILURE\", detail: \"Query value '").Append(prop.Name).Append("' could not be bound to type '").Append(prop.TypeFullName).AppendLine("'.\");"); + source.Append(" body.").Append(prop.Name).Append(" = ").Append(varName).AppendLine(";"); + } + source.AppendLine(" }"); + } + + // Server-set property reset (per-property, no runtime reflection) + foreach (var prop in serverSetProperties) + { + source.Append(" body.").Append(prop.Name).AppendLine(" = default;"); + } + + // Dispatch via Simple Injector scope + source.AppendLine(" var _container = global::Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(request.HttpContext.RequestServices);"); + source.AppendLine(" await using var _scope = global::SimpleInjector.Lifestyles.AsyncScopedLifestyle.BeginScope(_container);"); + + if (endpoint.Kind == HandlerKind.Command) + { + source.Append(" var _handler = _container.GetInstance>();"); + source.AppendLine(" await _handler.ExecuteAsync(body, cancellationToken).ConfigureAwait(false);"); + source.AppendLine(" return global::Microsoft.AspNetCore.Http.Results.NoContent();"); + } + else if (endpoint.Kind == HandlerKind.Query) + { + source.Append(" var _handler = _container.GetInstance>();"); + source.AppendLine(" var _result = await _handler.ExecuteAsync(body, cancellationToken).ConfigureAwait(false);"); + source.AppendLine(" return _result is null ? global::Microsoft.AspNetCore.Http.Results.NotFound() : global::Microsoft.AspNetCore.Http.Results.Ok(_result);"); + } + else + { + source.Append(" var _handler = _container.GetInstance>();"); + source.AppendLine(" var _result = await _handler.ExecuteAsync(body, cancellationToken).ConfigureAwait(false);"); + source.AppendLine(" return _result is null ? global::Microsoft.AspNetCore.Http.Results.NoContent() : global::Microsoft.AspNetCore.Http.Results.Ok(_result);"); + } + + source.AppendLine(" }"); + } + private readonly record struct HostInfo( INamedTypeSymbol Marker, string Prefix, @@ -212,6 +315,64 @@ private readonly record struct HostInfo( var introduced = GetNamedInt(versioning, "Introduced", 1); var retired = GetNamedInt(versioning, "Retired", 0); var messagePack = GetNamedBool(attribute, "AcceptsMessagePack"); + var kind = HandlerKind.None; + string? responseType = null; + foreach (var iface in type.AllInterfaces) + { + var definition = iface.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + if (definition == SolidRequest) + { + kind = HandlerKind.Request; + responseType = iface.TypeArguments[0].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + break; + } + if (definition == SolidQuery) + { + kind = HandlerKind.Query; + responseType = iface.TypeArguments[0].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + break; + } + if (definition == SolidCommand) + { + kind = HandlerKind.Command; + break; + } + } + if (kind == HandlerKind.None) + return null; + + // Extract route parameter names from the template + var routeNames = new HashSet( + _routeParamRegex.Matches(template!) + .Cast() + .Select(m => m.Groups["param"].Value) + .Where(n => !string.Equals(n, "version", StringComparison.OrdinalIgnoreCase)), + StringComparer.OrdinalIgnoreCase); + + // Extract per-property binding info at generation time (no runtime reflection per request) + var properties = AllProperties(type) + .Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic + && p.SetMethod is { DeclaredAccessibility: Accessibility.Public }) + .Select(p => + { + var routeAttr = p.GetAttributes() + .FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == HttpRouteAttribute); + var bindingName = routeAttr?.ConstructorArguments.FirstOrDefault().Value as string ?? p.Name; + var isRoute = routeAttr is not null || routeNames.Contains(p.Name); + var isQuery = p.GetAttributes().Any(a => a.AttributeClass?.ToDisplayString() == HttpQueryAttribute); + var isServerSet = p.GetAttributes().Any(a => a.AttributeClass?.ToDisplayString() == ServerSetAttribute); + var isString = p.Type.SpecialType == SpecialType.System_String; + return new PropertyInfo( + p.Name, + p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + isRoute, + bindingName, + isQuery, + isServerSet, + isString); + }) + .ToImmutableArray(); + return new Endpoint( type.Name, type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), @@ -223,7 +384,10 @@ private readonly record struct HostInfo( prefix, template, Math.Max(1, introduced), - retired); + retired, + kind, + responseType ?? "global::System.Void", + properties); } private static bool IsSelected( @@ -266,6 +430,13 @@ private static IEnumerable AllTypes(INamespaceSymbol space) } } + private static IEnumerable AllProperties(INamedTypeSymbol type) + { + for (var current = type; current is not null; current = current.BaseType) + foreach (var member in current.GetMembers().OfType()) + yield return member; + } + private static int GetNamedInt(AttributeData? attribute, string name, int fallback) { if (attribute is null) @@ -295,6 +466,8 @@ private static string Sanitize(string value) private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + private static string Literal(string value) => "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; + private static string ExpandRoute(string prefix, string template, int version) { var versionText = version.ToString(CultureInfo.InvariantCulture); @@ -304,6 +477,15 @@ private static string ExpandRoute(string prefix, string template, int version) return route.Trim('/'); } + private readonly record struct PropertyInfo( + string Name, + string TypeFullName, + bool IsRoute, + string BindingName, + bool IsQuery, + bool IsServerSet, + bool IsString); + private readonly record struct Endpoint( string TypeName, string FullyQualifiedType, @@ -315,5 +497,16 @@ private readonly record struct Endpoint( string Prefix, string Template, int Introduced, - int Retired); + int Retired, + HandlerKind Kind, + string ResponseType, + ImmutableArray Properties); + + private enum HandlerKind + { + None, + Request, + Query, + Command + } } diff --git a/src/mediator-framework/Ark.Tools.MediatorFramework.AzureFunctions/Ark.Tools.MediatorFramework.AzureFunctions.csproj b/src/mediator-framework/Ark.Tools.MediatorFramework.AzureFunctions/Ark.Tools.MediatorFramework.AzureFunctions.csproj index 7db4745f8..ea711cd97 100644 --- a/src/mediator-framework/Ark.Tools.MediatorFramework.AzureFunctions/Ark.Tools.MediatorFramework.AzureFunctions.csproj +++ b/src/mediator-framework/Ark.Tools.MediatorFramework.AzureFunctions/Ark.Tools.MediatorFramework.AzureFunctions.csproj @@ -16,7 +16,10 @@ + + + diff --git a/src/mediator-framework/Ark.Tools.MediatorFramework.AzureFunctions/ArkAzureFunctionsInvocation.cs b/src/mediator-framework/Ark.Tools.MediatorFramework.AzureFunctions/ArkAzureFunctionsInvocation.cs index 978f59563..bd544b54e 100644 --- a/src/mediator-framework/Ark.Tools.MediatorFramework.AzureFunctions/ArkAzureFunctionsInvocation.cs +++ b/src/mediator-framework/Ark.Tools.MediatorFramework.AzureFunctions/ArkAzureFunctionsInvocation.cs @@ -1,7 +1,16 @@ // Copyright (C) 2024 Ark Energy S.r.l. All rights reserved. // Licensed under the MIT License. See LICENSE file for license information. +using Ark.Tools.Solid; + using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +using SimpleInjector; +using SimpleInjector.Lifestyles; + +using System.Reflection; +using System.Text.Json; namespace Ark.MediatorFramework.AzureFunctions; @@ -12,15 +21,255 @@ public static class ArkAzureFunctionsInvocation /// Invokes the generated mediator pipeline for a request. /// /// The generated contract request type. + /// The request response type. /// The incoming ASP.NET Core request. /// The invocation cancellation token. /// The HTTP result produced by the mediator pipeline. - public static Task InvokeAsync( + public static async Task InvokeRequestAsync< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | DynamicallyAccessedMemberTypes.PublicProperties)] TRequest, + TResponse>( HttpRequest request, CancellationToken cancellationToken) + where TRequest : IRequest { ArgumentNullException.ThrowIfNull(request); - throw new NotSupportedException( - "Azure Functions mediator dispatch is implemented by the binding and dispatch task."); + var binding = await BindAsync(request, cancellationToken).ConfigureAwait(false); + if (!binding.Succeeded) + return Results.Problem(statusCode: 400, title: "BINDING_FAILURE", detail: binding.Error); + + var (container, scope) = BeginScope(request); + await using (scope.ConfigureAwait(false)) + { + var handler = container.GetInstance>(); + var result = await handler.ExecuteAsync(binding.Value!, cancellationToken).ConfigureAwait(false); + return result is null ? Results.NoContent() : Results.Ok(result); + } + } + + /// Invokes a generated query through the application container. + /// The generated query type. + /// The query response type. + /// The incoming ASP.NET Core request. + /// The invocation cancellation token. + /// The HTTP result produced by the query handler. + public static async Task InvokeQueryAsync< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | DynamicallyAccessedMemberTypes.PublicProperties)] TQuery, + TResponse>( + HttpRequest request, + CancellationToken cancellationToken) + where TQuery : IQuery + { + ArgumentNullException.ThrowIfNull(request); + var binding = await BindAsync(request, cancellationToken).ConfigureAwait(false); + if (!binding.Succeeded) + return Results.Problem(statusCode: 400, title: "BINDING_FAILURE", detail: binding.Error); + + var (container, scope) = BeginScope(request); + await using (scope.ConfigureAwait(false)) + { + var handler = container.GetInstance>(); + var result = await handler.ExecuteAsync(binding.Value!, cancellationToken).ConfigureAwait(false); + return result is null ? Results.NotFound() : Results.Ok(result); + } + } + + /// Invokes a generated command through the application container. + /// The generated command type. + /// The incoming ASP.NET Core request. + /// The invocation cancellation token. + /// The HTTP result produced by the command handler. + public static async Task InvokeCommandAsync< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | DynamicallyAccessedMemberTypes.PublicProperties)] TCommand>( + HttpRequest request, + CancellationToken cancellationToken) + where TCommand : ICommand + { + ArgumentNullException.ThrowIfNull(request); + var binding = await BindAsync(request, cancellationToken).ConfigureAwait(false); + if (!binding.Succeeded) + return Results.Problem(statusCode: 400, title: "BINDING_FAILURE", detail: binding.Error); + + var (container, scope) = BeginScope(request); + await using (scope.ConfigureAwait(false)) + { + var handler = container.GetInstance>(); + await handler.ExecuteAsync(binding.Value!, cancellationToken).ConfigureAwait(false); + return Results.NoContent(); + } + } + + private static (Container Container, Scope Scope) BeginScope(HttpRequest request) + { + var container = request.HttpContext.RequestServices.GetService() + ?? throw new InvalidOperationException( + "The Azure Functions mediator container is not registered. Call AddArkAzureFunctions with the application container."); + return (container, AsyncScopedLifestyle.BeginScope(container)); + } + + // ponytail: reflection on typeof(T) is performed once per T via the static generic cache PropertyCache; + // upgrade path is the source generator which emits per-property code with zero runtime reflection. + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Generated contract types are preserved by the source generator.")] + [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "Generated contract types are preserved by the source generator.")] + private static async Task> BindAsync< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | DynamicallyAccessedMemberTypes.PublicProperties)] T>( + HttpRequest request, + CancellationToken cancellationToken) + { + T? value; + if (request.ContentLength is > 0 || request.Headers.ContentType.ToString().Contains("json", StringComparison.OrdinalIgnoreCase)) + { + try + { + value = await request.ReadFromJsonAsync(cancellationToken).ConfigureAwait(false); + } + catch (JsonException ex) + { + return BindingResult.Fail("Request body could not be deserialized: " + ex.Message); + } + } + else + { + try + { + value = Activator.CreateInstance(); + } + catch (MissingMethodException) + { + return BindingResult.Fail("Contract type '" + typeof(T).Name + "' does not have a public parameterless constructor."); + } + } + + if (value is null) + return BindingResult.Fail("Request body deserialized to null."); + + foreach (var entry in PropertyCache.Entries) + { + if (entry.IsServerSet) + { + // Only reset writable properties; skip non-nullable value types to avoid InvalidCastException. + if (entry.Property.CanWrite && entry.IsNullableOrReference) + entry.Property.SetValue(value, null); + else if (entry.Property.CanWrite) + entry.Property.SetValue(value, entry.DefaultValue); + continue; + } + + var bindingName = entry.BindingName; + if (entry.IsRoute && request.RouteValues.TryGetValue(bindingName, out var route)) + { + if (!TryConvertObject(route?.ToString(), entry.Property.PropertyType, out var converted, out var convertError)) + return BindingResult.Fail("Route value '" + bindingName + "' could not be bound: " + convertError); + entry.Property.SetValue(value, converted); + } + + if (entry.IsQuery && request.Query.TryGetValue(entry.Property.Name, out var query)) + { + if (!TryConvertObject(query, entry.Property.PropertyType, out var converted, out var convertError)) + return BindingResult.Fail("Query value '" + entry.Property.Name + "' could not be bound: " + convertError); + entry.Property.SetValue(value, converted); + } + } + + return new BindingResult(value, true, null); + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "HTTP scalar types are handled by cached TypeConverter lookups; generated code uses ArkTypeConverter.TryConvert with known types.")] + [UnconditionalSuppressMessage("Trimming", "IL2067", Justification = "HTTP scalar types are handled by cached TypeConverter lookups; generated code uses ArkTypeConverter.TryConvert with known types.")] + private static bool TryConvertObject( + string? input, + Type type, + out object? value, + out string? error) + { + if (input is null) + { + value = null; + error = null; + if (type.IsValueType && Nullable.GetUnderlyingType(type) is null) + { + error = "null is not valid for non-nullable type '" + type.Name + "'."; + return false; + } + return true; + } + + var target = Nullable.GetUnderlyingType(type) ?? type; + try + { + if (target == typeof(string)) + { + value = input; + error = null; + return true; + } + + var converter = System.ComponentModel.TypeDescriptor.GetConverter(target); + value = converter.ConvertFromString(null, System.Globalization.CultureInfo.InvariantCulture, input); + error = null; + return true; + } + catch (Exception ex) when (ex is FormatException + or NotSupportedException + or InvalidCastException + or OverflowException + or ArgumentException) + { + value = null; + error = "'" + input + "' cannot be converted to " + target.Name + ": " + ex.Message; + return false; + } + } + + // Static generic cache: typeof(T).GetProperties() runs exactly once per T. + private static class PropertyCache<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T> + { + public static readonly PropertyEntry[] Entries = Build(); + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "PropertyCache is only used for T types preserved by the source generator.")] + [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "PropertyInfo.PropertyType refers to types preserved by the source generator.")] + private static PropertyEntry[] Build() + { + return typeof(T) + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Select(p => + { + var routeAttr = p.CustomAttributes.FirstOrDefault(a => + string.Equals(a.AttributeType.FullName, "Ark.MediatorFramework.HttpRouteAttribute", StringComparison.Ordinal)); + var bindingName = routeAttr?.ConstructorArguments.FirstOrDefault().Value as string ?? p.Name; + var isRoute = routeAttr is not null; + var isQuery = p.CustomAttributes.Any(a => + string.Equals(a.AttributeType.FullName, "Ark.MediatorFramework.HttpQueryAttribute", StringComparison.Ordinal)); + var isServerSet = p.CustomAttributes.Any(a => + string.Equals(a.AttributeType.FullName, "Ark.MediatorFramework.ServerSetAttribute", StringComparison.Ordinal)); + var propType = p.PropertyType; + var isNullableOrRef = !propType.IsValueType || Nullable.GetUnderlyingType(propType) is not null; + var defaultValue = propType.IsValueType ? Activator.CreateInstance(propType) : null; + return new PropertyEntry(p, bindingName, isRoute, isQuery, isServerSet, isNullableOrRef, defaultValue); + }) + .ToArray(); + } + } + + private sealed class PropertyEntry( + PropertyInfo property, + string bindingName, + bool isRoute, + bool isQuery, + bool isServerSet, + bool isNullableOrReference, + object? defaultValue) + { + public PropertyInfo Property { get; } = property; + public string BindingName { get; } = bindingName; + public bool IsRoute { get; } = isRoute; + public bool IsQuery { get; } = isQuery; + public bool IsServerSet { get; } = isServerSet; + public bool IsNullableOrReference { get; } = isNullableOrReference; + public object? DefaultValue { get; } = defaultValue; + } + + private readonly record struct BindingResult(T? Value, bool Succeeded, string? Error) + { + public static BindingResult Fail(string error) => new(default, false, error); } } diff --git a/src/mediator-framework/Ark.Tools.MediatorFramework.AzureFunctions/ArkAzureFunctionsServiceCollectionExtensions.cs b/src/mediator-framework/Ark.Tools.MediatorFramework.AzureFunctions/ArkAzureFunctionsServiceCollectionExtensions.cs index 45cc90832..52f9b7b52 100644 --- a/src/mediator-framework/Ark.Tools.MediatorFramework.AzureFunctions/ArkAzureFunctionsServiceCollectionExtensions.cs +++ b/src/mediator-framework/Ark.Tools.MediatorFramework.AzureFunctions/ArkAzureFunctionsServiceCollectionExtensions.cs @@ -3,6 +3,12 @@ using Microsoft.Extensions.DependencyInjection; +using SimpleInjector; + +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + namespace Ark.MediatorFramework.AzureFunctions; /// Registers the runtime services used by generated Azure Functions. @@ -10,12 +16,60 @@ public static class ArkAzureFunctionsServiceCollectionExtensions { /// /// Registers the Azure Functions mediator runtime services. + /// Configures HTTP JSON binding with Ark defaults (camelCase, NodaTime, enum-as-member). /// /// The service collection to configure. + /// + /// Optional source-generated instances to include in the + /// type-info resolver chain. When provided, types in these contexts are resolved without + /// reflection. A fallback is always appended. + /// /// The same service collection. - public static IServiceCollection AddArkAzureFunctions(this IServiceCollection services) + [UnconditionalSuppressMessage("Trimming", "IL2026", + Justification = "DefaultJsonTypeInfoResolver is only used as a fallback for types not covered by the supplied source-generated contexts.")] + public static IServiceCollection AddArkAzureFunctions( + this IServiceCollection services, + params JsonSerializerContext[] additionalContexts) { ArgumentNullException.ThrowIfNull(services); + + services.ConfigureHttpJsonOptions(options => + { + options.SerializerOptions.ConfigureArkDefaults(); + IJsonTypeInfoResolver resolver = new DefaultJsonTypeInfoResolver(); + if (additionalContexts.Length > 0) + { + var resolvers = new IJsonTypeInfoResolver[additionalContexts.Length + 1]; + for (var i = 0; i < additionalContexts.Length; i++) + resolvers[i] = additionalContexts[i]; + resolvers[additionalContexts.Length] = new DefaultJsonTypeInfoResolver(); + resolver = JsonTypeInfoResolver.Combine(resolvers); + } + + options.SerializerOptions.TypeInfoResolver = resolver; + }); + return services; } + + /// + /// Registers the Azure Functions mediator runtime services and the application container. + /// + /// The service collection to configure. + /// The application Simple Injector container. + /// + /// Optional source-generated instances to include in the + /// type-info resolver chain. + /// + /// The same service collection. + public static IServiceCollection AddArkAzureFunctions( + this IServiceCollection services, + Container container, + params JsonSerializerContext[] additionalContexts) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(container); + services.AddSingleton(container); + return services.AddArkAzureFunctions(additionalContexts); + } } diff --git a/src/mediator-framework/Ark.Tools.MediatorFramework.AzureFunctions/packages.lock.json b/src/mediator-framework/Ark.Tools.MediatorFramework.AzureFunctions/packages.lock.json index a619c632f..7623ecccf 100644 --- a/src/mediator-framework/Ark.Tools.MediatorFramework.AzureFunctions/packages.lock.json +++ b/src/mediator-framework/Ark.Tools.MediatorFramework.AzureFunctions/packages.lock.json @@ -83,6 +83,12 @@ "resolved": "11.0.1", "contentHash": "9Jqm26Yb5D833BSU6rH3taFB8Y+A67Y3GJRC6XFEBKc2ueQSfd0B4bU3cAGsUNrWUJd1V9Sgm5U7I8SNG5zJvA==" }, + "SimpleInjector": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "6LAI0eDUtm57Ruvnoqf9FpZ7YXEHtUnsupIyYsPyeVKEDwGEsXWbNk/8z1HhI3YCiLvyPxcov4eqcs7Fb5bD1A==" + }, "Azure.Core": { "type": "Transitive", "resolved": "1.44.1", @@ -93,6 +99,11 @@ "System.Memory.Data": "6.0.0" } }, + "BouncyCastle.Cryptography": { + "type": "Transitive", + "resolved": "2.6.2", + "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + }, "Grpc.Core.Api": { "type": "Transitive", "resolved": "2.80.0", @@ -157,6 +168,11 @@ "resolved": "6.0.0", "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "LG9Yll3B5aNpxv0+D47g6LiOiKBIlodhcHdQwcYzo8VeexFLGqx5ymetmA2aBRyo9cCcWsQWrFsdbsr8LvmWDw==" + }, "Microsoft.Build.Tasks.Git": { "type": "Transitive", "resolved": "10.0.301", @@ -165,11 +181,65 @@ "System.IO.Hashing": "10.0.10" } }, + "Microsoft.Data.SqlClient.Extensions.Abstractions": { + "type": "Transitive", + "resolved": "7.0.2", + "contentHash": "Zx7z61fG2Nc6LdDn4jA7b5Aj1ABrljkOPRE31RvVUdjF6IgbG60leDUhMCafsnWFgaheiK3iqpLe+kbf5Z5L8Q==", + "dependencies": { + "Microsoft.Data.SqlClient.Internal.Logging": "[7.0.2, 8.0.0)" + } + }, + "Microsoft.Data.SqlClient.Internal.Logging": { + "type": "Transitive", + "resolved": "7.0.2", + "contentHash": "iqgYBbGSVy/DIYWjzmQOa964Kn4rs4wW5vg2IamqVK0fQqfTiILVR5hMK27XWqoyONtAZs+VxH354cPJBtSnbg==" + }, + "Microsoft.Data.SqlClient.SNI.runtime": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "f+pRODTWX7Y67jXO3T5S2dIPZ9qMJNySjlZT/TKmWVNWe19N8jcWmHaqHnnchaq3gxEKv1SWVY5EFzOD06l41w==" + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.21.0", + "contentHash": "XYgEmYpUvng0oYIagDJ+uebboQSnWtCV7q9zMyXlBw6ZqLLCHcmGUb7TpXvQ3HWY4OzbOCUQm2Ac0saPSnwqSw==" + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.21.0", + "contentHash": "LgIWUUX386xCbvjq2ex8iXGIu2raDWrrIQ4OyYZKaNWMhzvN8YOTbAYMFJC+iHmKnkNBM8g7mLak9KQodldH3g==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.21.0" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "8.21.0", + "contentHash": "xFnFEl4VrhAJEbqtRrpgfAACgkm6NYo7cWYKYROH00N9/M5OYrRwTP0lHsginnLrqPrseLcSqAueQNRCyGNsdw==", + "dependencies": { + "Microsoft.Bcl.Cryptography": "10.0.2", + "Microsoft.IdentityModel.Logging": "8.21.0" + } + }, "Microsoft.SourceLink.Common": { "type": "Transitive", "resolved": "10.0.301", "contentHash": "oz44EclJdAYVZcRb9dSbWd+6RaE+8a4j5zdE+O7yw5qPRZZhu3pcpDZ/Moy6pLeIXZqBP1FMd8CSE0sgJAGV0g==" }, + "Microsoft.SqlServer.Server": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "N4KeF3cpcm1PUHym1RmakkzfkEv3GRMyofVv40uXsQhCQeglr2OHNcUk2WOG51AKpGO8ynGpo9M/kFXSzghwug==" + }, + "MimeKit": { + "type": "Transitive", + "resolved": "4.17.0", + "contentHash": "h/KXsCreJf8RpR/PSAtlbvYtZFndp9N8Wn1Bs248AL7ITyhn+AiIY5bLTvt60tbN2mu6g8KadtBNWygTji2lqg==", + "dependencies": { + "BouncyCastle.Cryptography": "2.6.2", + "System.Security.Cryptography.Pkcs": "10.0.0" + } + }, "protobuf-net.Core": { "type": "Transitive", "resolved": "3.2.56", @@ -183,6 +253,11 @@ "System.Memory.Data": "1.0.2" } }, + "System.IO.FileSystem.AccessControl": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "vMToiarpU81LR1/KZtnT7VDPvqAZfw9oOS5nY6pPP78nGYz3COLsQH3OfzbR+SjTgltd31R6KmKklz/zDpTmzw==" + }, "System.IO.Hashing": { "type": "Transitive", "resolved": "10.0.10", @@ -193,12 +268,88 @@ "resolved": "6.0.0", "contentHash": "ntFHArH3I4Lpjf5m4DCXQHJuGwWPNVJPaAvM95Jy/u+2Yzt2ryiyIN04LAogkjP9DeRcEOiviAjQotfmPq/FrQ==" }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "UPWqLSygJlFerRi9XNIuM0a1VC8gHUIufyP24xQ0sc+XimqUAEcjpOz9DhKpyDjH+5B/wO3RpC0KpkEeDj/ddg==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "BKt0SQgq2lq3ESE68jkeLwv95ypANrPDtkTOIFGcnhg2aRUeUUBDrQlkVDZctrg1WenVcvn5P5XZnuYI7q6rFQ==" + }, + "ark.tools.applicationinsights": { + "type": "Project", + "dependencies": { + "Microsoft.ApplicationInsights": "[2.23.0, )", + "Microsoft.ApplicationInsights.SnapshotCollector": "[1.4.6, )", + "Microsoft.ApplicationInsights.WindowsServer.TelemetryChannel": "[2.23.0, )", + "Microsoft.Data.SqlClient": "[7.0.2, )" + } + }, + "ark.tools.core": { + "type": "Project", + "dependencies": { + "NodaTime": "[3.3.3, )" + } + }, "ark.tools.mediatorframework": { "type": "Project", "dependencies": { "protobuf-net": "[3.2.56, )" } }, + "ark.tools.nlog": { + "type": "Project", + "dependencies": { + "Ark.Tools.ApplicationInsights": "[1.0.0, )", + "Ark.Tools.Core": "[1.0.0, )", + "Ark.Tools.SystemTextJson": "[1.0.0, )", + "Ben.Demystifier": "[0.4.1, )", + "Microsoft.ApplicationInsights.NLogTarget": "[2.23.0, )", + "Microsoft.Data.SqlClient": "[7.0.2, )", + "NLog": "[6.1.4, )", + "NLog.Database": "[6.0.3, )", + "NLog.DiagnosticSource": "[6.1.4, )", + "NLog.MailKit": "[6.1.5, )", + "Newtonsoft.Json": "[13.0.4, )", + "Slack.Webhooks": "[1.1.6, )" + } + }, + "ark.tools.nodatime": { + "type": "Project", + "dependencies": { + "Ark.Tools.Core": "[1.0.0, )" + } + }, + "ark.tools.nodatime.systemtextjson": { + "type": "Project", + "dependencies": { + "Ark.Tools.Nodatime": "[1.0.0, )", + "NodaTime.Serialization.SystemTextJson": "[1.4.0, )" + } + }, + "ark.tools.solid": { + "type": "Project", + "dependencies": { + "Ark.Tools.Core": "[1.0.0, )", + "Ark.Tools.NLog": "[1.0.0, )" + } + }, + "ark.tools.systemtextjson": { + "type": "Project", + "dependencies": { + "Ark.Tools.Core": "[1.0.0, )", + "Ark.Tools.Nodatime.SystemTextJson": "[1.0.0, )", + "Macross.Json.Extensions": "[3.0.0, )" + } + }, + "Ben.Demystifier": { + "type": "CentralTransitive", + "requested": "[0.4.1, )", + "resolved": "0.4.1", + "contentHash": "axFeEMfmEORy3ipAzOXG/lE+KcNptRbei3F0C4kQCdeiQtW+qJW90K5iIovITGrdLt8AjhNCwk5qLSX9/rFpoA==" + }, "Google.Protobuf": { "type": "CentralTransitive", "requested": "[3.35.1, )", @@ -214,6 +365,157 @@ "Grpc.Net.Common": "2.80.0" } }, + "Macross.Json.Extensions": { + "type": "CentralTransitive", + "requested": "[3.0.0, )", + "resolved": "3.0.0", + "contentHash": "AkNshs6dopj8FXsmkkJxvLivN2SyDJQDbjcds5lo9+Y6L4zpcoXdmzXQ3VVN+AIWQr0CTD5A7vkuHGAr2aypZg==" + }, + "MailKit": { + "type": "CentralTransitive", + "requested": "[4.17.0, )", + "resolved": "4.17.0", + "contentHash": "1nUAVLxM9fhT/78we6/AGsCesnpn5dRNLLeRqOfr52Wnk87pzVwo5YMTMyqnmoXrYc7piGhmayiMA/OgDluIjg==", + "dependencies": { + "MimeKit": "4.17.0" + } + }, + "Microsoft.ApplicationInsights": { + "type": "CentralTransitive", + "requested": "[2.23.0, )", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.ApplicationInsights.NLogTarget": { + "type": "CentralTransitive", + "requested": "[2.23.0, )", + "resolved": "2.23.0", + "contentHash": "rjUqSw8SLCB7timNT/Brz92mumWv4audq2diIj67xKibdcK93kh+QaMj/G+OiNrsqfi2K+0CxmIZCALTi19Obg==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "NLog": "4.5.11" + } + }, + "Microsoft.ApplicationInsights.SnapshotCollector": { + "type": "CentralTransitive", + "requested": "[1.4.6, )", + "resolved": "1.4.6", + "contentHash": "UGXpUjW3YFSFq+u4CXwJrU3Rf7Hc3dMrMVTBJ8E3LB0eV2MF8lOHnXc+kHEmLKO4gxHhvulaVIld7U5aDzLZ8A==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.15.0", + "System.IO.FileSystem.AccessControl": "4.7.0" + } + }, + "Microsoft.ApplicationInsights.WindowsServer.TelemetryChannel": { + "type": "CentralTransitive", + "requested": "[2.23.0, )", + "resolved": "2.23.0", + "contentHash": "798Dudr4tkujslk1w+XcXOcCErmVsk+nhp+QCHLa3lcgi25vkAxBmzPUeQlRJVCNL/1f4x/YF+vQZ8RSuTXWCw==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "System.IO.FileSystem.AccessControl": "4.7.0" + } + }, + "Microsoft.Data.SqlClient": { + "type": "CentralTransitive", + "requested": "[7.0.2, )", + "resolved": "7.0.2", + "contentHash": "zwv76lANFQQI6Gmp6ntkzMWIWVqm8Wf4Mz00AeGCk1n8HCi5afi6bNynSe18uI0xeL0n6J+Myjk9AiIsL5oSqw==", + "dependencies": { + "Microsoft.Bcl.Cryptography": "9.0.13", + "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.0.2, 8.0.0)", + "Microsoft.Data.SqlClient.Internal.Logging": "[7.0.2, 8.0.0)", + "Microsoft.Data.SqlClient.SNI.runtime": "[6.0.2, 7.0.0)", + "Microsoft.IdentityModel.JsonWebTokens": "8.16.0", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.16.0", + "Microsoft.SqlServer.Server": "[1.0.0, 2.0.0)", + "System.Configuration.ConfigurationManager": "9.0.13", + "System.Security.Cryptography.Pkcs": "9.0.13" + } + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "CentralTransitive", + "requested": "[8.21.0, )", + "resolved": "8.21.0", + "contentHash": "iiA5uimwlRe4Drd8YKOaB8IQkgGOjxGjO6mu5zCGFjaEBpB6yNOluZ33zJFXiW5MQYRC6ypMnOXsiR5SPrO+tw==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.21.0" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "CentralTransitive", + "requested": "[8.21.0, )", + "resolved": "8.21.0", + "contentHash": "83hO6NhkUSaOFLeQmIzUSoCgVLhzGc+JMQZvo5sKw51NDBmVfoUPcJiMvyJtyrwTdwxNNqkcjjwhC5ktP449bw==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.21.0" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "CentralTransitive", + "requested": "[8.21.0, )", + "resolved": "8.21.0", + "contentHash": "V6A4CFcST3M6r4fFuPw2qpjZjw9QkWrN3kPCn3GT3GbsMpuae7QYu18jR+9peGgyFR3HCSXUba+pB/Lm9Vmk9A==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "8.21.0", + "System.IdentityModel.Tokens.Jwt": "8.21.0" + } + }, + "Newtonsoft.Json": { + "type": "CentralTransitive", + "requested": "[13.0.4, )", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "NLog": { + "type": "CentralTransitive", + "requested": "[6.1.4, )", + "resolved": "6.1.4", + "contentHash": "ft6Lv17uoFuHTGWiY47FEXH7VA5HMHp973zodaOZAJyFg6mHL9RdXjqdNkRJXFo5plQJR+LPt5e6aAZU8KSDog==" + }, + "NLog.Database": { + "type": "CentralTransitive", + "requested": "[6.0.3, )", + "resolved": "6.0.3", + "contentHash": "uMzSeSQbjHjAG+zF6E7DudXyLhyxNObBtHkyQE+IoLudqadKR1xojGsk9z02fWNlcyvCb3odkPh7Cf71eBi0/Q==", + "dependencies": { + "NLog": "6.0.3" + } + }, + "NLog.DiagnosticSource": { + "type": "CentralTransitive", + "requested": "[6.1.4, )", + "resolved": "6.1.4", + "contentHash": "hJQn9R7SXRRrbVM1jW9N6EmGbPejZOy1IOee8L7LcMyX/1kwBxxwZl99T/jzel16xG5eXs1xaqCjRYejlgJyog==", + "dependencies": { + "NLog": "6.1.4" + } + }, + "NLog.MailKit": { + "type": "CentralTransitive", + "requested": "[6.1.5, )", + "resolved": "6.1.5", + "contentHash": "khJJ6sjKBzY5KR5l6RLyt3WchiELKY9N952WDwvX8Lv36sdtbx+VtTyu9pTtrYGaNQGlGjiVb5d8U1VWKzK7fA==", + "dependencies": { + "MailKit": "4.17.0", + "NLog": "6.1.4" + } + }, + "NodaTime": { + "type": "CentralTransitive", + "requested": "[3.3.3, )", + "resolved": "3.3.3", + "contentHash": "EwouHZv99j5hraq34yQqvCQGDR59dynxPHlxFm2vY/sw2N2aR8cFel29ZxPqohjBWp9Qsnld7QCY+7uZ2PESGg==" + }, + "NodaTime.Serialization.SystemTextJson": { + "type": "CentralTransitive", + "requested": "[1.4.0, )", + "resolved": "1.4.0", + "contentHash": "jGe0WLNAoRb3efOgsa5iecmMuzP+LNdtw2Rs4N9+2pS4YN+kg8e9Z48Jk47WWebcpqF81YdWT2KNInbXZXTS7w==", + "dependencies": { + "NodaTime": "[3.0.0, 4.0.0)" + } + }, "protobuf-net": { "type": "CentralTransitive", "requested": "[3.2.56, )", @@ -222,6 +524,34 @@ "dependencies": { "protobuf-net.Core": "3.2.56" } + }, + "Slack.Webhooks": { + "type": "CentralTransitive", + "requested": "[1.1.6, )", + "resolved": "1.1.6", + "contentHash": "Xl16kn26BwU71QEv9kn+TVpnIr3QiwJJL/JyQkUdIgAN6l0fubMtK4eU5OR3g/BcPlXSKWWScH3gNrbagK/Dww==", + "dependencies": { + "Newtonsoft.Json": "13.0.4" + } + }, + "System.Configuration.ConfigurationManager": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "64jtHt9yLYi6VHMF5AwDj6BGM/MZrygv55SXl0MOIkFsFjGwRuxV3/QgEm7NTp3TQIx+YNX4S6dNpY08fJ3+3g==", + "dependencies": { + "System.Security.Cryptography.ProtectedData": "10.0.10" + } + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "CentralTransitive", + "requested": "[8.21.0, )", + "resolved": "8.21.0", + "contentHash": "dKsTkmlpUf/I6g1Aw9joWaZAahXvC0gWGQvkXW1IjZLSefC/4LhQI0tL0VIs64/NZQbl4XnQvLQyu6c7z1pMxg==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "8.21.0", + "Microsoft.IdentityModel.Tokens": "8.21.0" + } } } } diff --git a/tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs b/tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs index b987367af..fe693993c 100644 --- a/tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs +++ b/tests/Ark.Tools.MediatorFramework.Tests/GeneratorSnapshotTests.cs @@ -145,13 +145,59 @@ public sealed class GetGreeting : IQuery { } result.Generated.Should().Contain("Function(\"GetGreeting_v2\")"); result.Generated.Should().Contain("Route = \"api/v2/greetings/{id}\""); result.Generated.Should().Contain("AuthorizationLevel.Anonymous"); - result.Generated.Should().Contain("InvokeAsync"); + result.Generated.Should().Contain("IQueryHandler diagnostic.Id == "ARKMF030" || diagnostic.Id == "ARKMF031" || diagnostic.Id == "ARKMF032"); } + [TestMethod] + public void AzureFunctionsGeneratorEmitsRouteBindingWithTryConvertSafe() + { + var result = RunGeneratorResult( + """ + using Ark.MediatorFramework; + using Ark.Tools.Solid; + [assembly: Ark.MediatorFramework.HttpHost(typeof(ContractMarker), "/api")] + public sealed class ContractMarker { } + [HttpEndpoint("GET", "/items/{id}")] + public sealed class GetItem : IQuery + { + public int Id { get; set; } + } + """); + + result.Generated.Should().Contain("ArkTypeConverter.TryConvertSafe"); + result.Generated.Should().NotContain("ArkTypeConverter.TryConvert"); + result.Generated.Should().Contain("BINDING_FAILURE"); + result.Generated.Should().NotContain("InvokeQueryAsync"); + } + + [TestMethod] + public void AzureFunctionsGeneratorSkipsConverterForStringBinding() + { + var result = RunGeneratorResult( + """ + using Ark.MediatorFramework; + using Ark.Tools.Solid; + [assembly: Ark.MediatorFramework.HttpHost(typeof(ContractMarker), "/api")] + public sealed class ContractMarker { } + [HttpEndpoint("GET", "/items/{name}")] + public sealed class GetItem : IQuery + { + [HttpRoute("name")] + public string Name { get; set; } + } + """); + + result.Generated.Should().NotContain("ArkTypeConverter"); + result.Generated.Should().Contain("?.ToString()"); + result.Generated.Should().NotContain("InvokeQueryAsync"); + } + [TestMethod] public void AzureFunctionsGeneratorReportsMessagePackEndpoints() { diff --git a/tests/Ark.Tools.MediatorFramework.Tests/packages.lock.json b/tests/Ark.Tools.MediatorFramework.Tests/packages.lock.json index f6c86921a..a87067c20 100644 --- a/tests/Ark.Tools.MediatorFramework.Tests/packages.lock.json +++ b/tests/Ark.Tools.MediatorFramework.Tests/packages.lock.json @@ -758,8 +758,11 @@ "type": "Project", "dependencies": { "Ark.Tools.MediatorFramework": "[1.0.0, )", + "Ark.Tools.Solid": "[1.0.0, )", + "Ark.Tools.SystemTextJson": "[1.0.0, )", "Microsoft.Azure.Functions.Worker": "[2.52.0, )", - "Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore": "[2.1.1, )" + "Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore": "[2.1.1, )", + "SimpleInjector": "[5.6.0, )" } }, "ark.tools.mediatorframework.azurefunctions.generators": {