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": {