| | | 1 | | using System.Collections.Concurrent; |
| | | 2 | | using System.Diagnostics.CodeAnalysis; |
| | | 3 | | using System.Linq.Expressions; |
| | | 4 | | using System.Reflection; |
| | | 5 | | using System.Text.Json; |
| | | 6 | | |
| | | 7 | | namespace AsyncResponse; |
| | | 8 | | |
| | | 9 | | /// <summary> |
| | | 10 | | /// Conversion and invocation helpers for the reflection-based callback machinery: |
| | | 11 | | /// materializing untyped JSON payloads as CLR types, resolving placeholder parameters, and |
| | | 12 | | /// invoking <see cref="ReflectionInvocationDto"/>s against the DI container. |
| | | 13 | | /// </summary> |
| | | 14 | | internal static class ReflectionExtensions |
| | | 15 | | { |
| | | 16 | | private delegate ValueTask AsyncMethodInvoker(object service, object?[] args); |
| | | 17 | | |
| | | 18 | | private static readonly ConcurrentDictionary<string, Type> ServiceTypes = new(StringComparer.Ordinal); |
| | | 19 | | |
| | | 20 | | // Names that already failed a full assembly scan. Capacity-bounded so hostile inputs cannot |
| | | 21 | | // grow it without limit, and invalidated on the only events that can turn a miss into a hit — |
| | | 22 | | // a new assembly loading, or a custom resolver registering. At capacity, novel unresolvable |
| | | 23 | | // names fall back to scanning; correctness never depends on this cache. |
| | | 24 | | // |
| | | 25 | | // Entries are stamped with the invalidation GENERATION observed before their failed scan, not |
| | | 26 | | // just stored: a plain clear-on-register has a race — an in-flight miss that started against |
| | | 27 | | // the old resolver set can insert AFTER the clear, permanently poisoning the name. A stale |
| | | 28 | | // stamp (generation advanced mid-scan) makes the entry a non-hit, so the next lookup rescans |
| | | 29 | | // with the new resolvers. |
| | | 30 | | private static readonly ConcurrentDictionary<string, int> UnresolvableServiceTypes = new(StringComparer.Ordinal); |
| | | 31 | | private const int UnresolvableServiceTypeCacheCapacity = 1024; |
| | | 32 | | private static int _unresolvableGeneration; |
| | | 33 | | |
| | | 34 | | // The AssemblyLoad invalidation hook is registered on first use (EnsureAssemblyLoadInvalidation |
| | | 35 | | // below), NOT from a static constructor and NOT from a module initializer: an explicit static |
| | | 36 | | // ctor forfeits beforefieldinit, adding a class-initialization check to every static access — |
| | | 37 | | // including the hand-tuned ConvertTo/As<T> hot path this file is benchmarked for — and |
| | | 38 | | // [ModuleInitializer] is analyzer-banned in library code (CA2255). First-call registration |
| | | 39 | | // costs one volatile read per type resolution, off the conversion hot path entirely. |
| | | 40 | | private static readonly object _assemblyLoadGate = new(); |
| | | 41 | | private static bool _assemblyLoadHooked; |
| | | 42 | | |
| | | 43 | | private static void EnsureAssemblyLoadInvalidation() |
| | | 44 | | { |
| | | 45 | | if (Volatile.Read(ref _assemblyLoadHooked)) |
| | | 46 | | return; |
| | | 47 | | |
| | | 48 | | // Attach-then-publish under a gate: publishing the flag before the handler was attached |
| | | 49 | | // let a concurrent thread proceed past the fast path, cache a miss, and race an assembly |
| | | 50 | | // load into the unhooked window — a false negative that nothing would ever invalidate. |
| | | 51 | | // The lock is cold-path only; the steady state is the single volatile read above. |
| | | 52 | | lock (_assemblyLoadGate) |
| | | 53 | | { |
| | | 54 | | if (_assemblyLoadHooked) |
| | | 55 | | return; |
| | | 56 | | |
| | | 57 | | AppDomain.CurrentDomain.AssemblyLoad += static (_, _) => InvalidateUnresolvableServiceTypes(); |
| | | 58 | | Volatile.Write(ref _assemblyLoadHooked, true); |
| | | 59 | | } |
| | | 60 | | } |
| | | 61 | | |
| | | 62 | | /// <summary> |
| | | 63 | | /// Invalidates the negative type-resolution cache (a new resolver or assembly may resolve |
| | | 64 | | /// cached misses). The generation bump is what guarantees correctness for in-flight scans; |
| | | 65 | | /// the clear just reclaims memory. |
| | | 66 | | /// </summary> |
| | | 67 | | internal static void InvalidateUnresolvableServiceTypes() |
| | | 68 | | { |
| | | 69 | | Interlocked.Increment(ref _unresolvableGeneration); |
| | | 70 | | UnresolvableServiceTypes.Clear(); |
| | | 71 | | } |
| | | 72 | | private static readonly ConcurrentDictionary<Type, ConversionPlan> ConversionPlans = new(); |
| | | 73 | | private static readonly ConcurrentDictionary<InvocationPlanKey, InvocationPlan> InvocationPlans = new(); |
| | | 74 | | private static readonly MethodInfo ToValueTaskMethod = typeof(ReflectionExtensions) |
| | | 75 | | .GetMethod(nameof(ToValueTask), BindingFlags.NonPublic | BindingFlags.Static)!; |
| | | 76 | | private static readonly MethodInfo AwaitGenericValueTaskMethod = typeof(ReflectionExtensions) |
| | | 77 | | .GetMethod(nameof(AwaitGenericValueTask), BindingFlags.NonPublic | BindingFlags.Static)!; |
| | | 78 | | |
| | | 79 | | /// <summary> |
| | | 80 | | /// If <paramref name="o"/> is a <see cref="JsonElement"/> (or a JSON string), deserializes it |
| | | 81 | | /// into <typeparamref name="T"/>; if it already is a <typeparamref name="T"/>, casts it; |
| | | 82 | | /// otherwise falls back to <see cref="Convert.ChangeType(object, Type)"/>. |
| | | 83 | | /// Throws if <paramref name="o"/> is null and <typeparamref name="T"/> is a non-nullable value type. |
| | | 84 | | /// </summary> |
| | | 85 | | public static T As<T>(this object? o) => (T)o.ConvertTo(typeof(T))!; |
| | | 86 | | |
| | | 87 | | /// <summary> |
| | | 88 | | /// Non-generic counterpart of <see cref="As{T}"/> for callers that only know the target type |
| | | 89 | | /// at runtime (e.g. classifying a payload against the type stored in the recovery state). |
| | | 90 | | /// </summary> |
| | | 91 | | public static object? ConvertTo(this object? o, Type targetType) |
| | | 92 | | => GetConversionPlan(targetType).Convert(o); |
| | | 93 | | |
| | | 94 | | /// <summary> |
| | | 95 | | /// Resolves the requested service from the provider and invokes the described method, |
| | | 96 | | /// converting each parameter to the method's parameter type via <see cref="ConvertTo"/>. |
| | | 97 | | /// </summary> |
| | | 98 | | public static Task InvokeAsync(this IServiceProvider provider, ReflectionInvocationDto dto) |
| | | 99 | | { |
| | | 100 | | try |
| | | 101 | | { |
| | | 102 | | // 1) Opt-in authorization — deliberately BEFORE any type resolution: the check is |
| | | 103 | | // string-based, so an unauthorized name is rejected without paying the assembly scan |
| | | 104 | | // (otherwise attacker-reachable work under the very threat model the authorizer |
| | | 105 | | // exists for). When an IAsyncResponseCallbackAuthorizer is registered, only allowed |
| | | 106 | | // (service, method) pairs may be invoked — defense-in-depth even if the recovery |
| | | 107 | | // store or worker transport is compromised. No authorizer registered ⇒ allow all. The |
| | | 108 | | // built-in flow executor gets no exemption: RecoverAsync carries an attacker-choosable |
| | | 109 | | // payload that is checkpointed into the flow ledger, so under the stated threat model it |
| | | 110 | | // must be gated like every other target. The AuthorizeCallbacks allowlist includes it by |
| | | 111 | | // default (see AsyncResponseCallbackAllowList.AllowDurableFlowExecutor); custom |
| | | 112 | | // authorizers must allow it explicitly when durable flows are enabled. |
| | | 113 | | if (provider.GetService(typeof(IAsyncResponseCallbackAuthorizer)) is IAsyncResponseCallbackAuthorizer author |
| | | 114 | | && !authorizer.IsAllowed(dto.ServiceInterfaceFullName, dto.MethodName)) |
| | | 115 | | { |
| | | 116 | | throw new InvalidOperationException( |
| | | 117 | | $"Callback target '{dto.ServiceInterfaceFullName}.{dto.MethodName}' is not authorized by the registe |
| | | 118 | | $"{nameof(IAsyncResponseCallbackAuthorizer)}; add it to the allowlist (AuthorizeCallbacks) to permit |
| | | 119 | | } |
| | | 120 | | |
| | | 121 | | // 2) Load the service type by full name |
| | | 122 | | var serviceType = ResolveServiceType(dto.ServiceInterfaceFullName); |
| | | 123 | | |
| | | 124 | | if (serviceType == null) |
| | | 125 | | throw new InvalidOperationException( |
| | | 126 | | $"Type '{dto.ServiceInterfaceFullName}' not found in loaded assemblies."); |
| | | 127 | | |
| | | 128 | | // 3) Resolve the service instance |
| | | 129 | | var service = provider.GetService(serviceType) |
| | | 130 | | ?? throw new InvalidOperationException( |
| | | 131 | | $"Service '{dto.ServiceInterfaceFullName}' is not registered."); |
| | | 132 | | |
| | | 133 | | // 4) Resolve and cache method metadata + compiled invocation delegate. |
| | | 134 | | var plan = InvocationPlans.GetOrAdd( |
| | | 135 | | new InvocationPlanKey(serviceType, dto.MethodName, dto.Params.Length), |
| | | 136 | | static key => CreateInvocationPlan(key)); |
| | | 137 | | |
| | | 138 | | // 5) Convert only the arguments that need conversion, keeping already-typed arrays hot. |
| | | 139 | | var invocationArgs = plan.ConvertArguments(dto.Params); |
| | | 140 | | |
| | | 141 | | // 6) Invoke through the compiled delegate and await Task/ValueTask results. |
| | | 142 | | var pending = plan.Invoke(service, invocationArgs); |
| | | 143 | | return pending.IsCompletedSuccessfully ? Task.CompletedTask : AwaitSlow(pending); |
| | | 144 | | } |
| | | 145 | | catch (Exception ex) |
| | | 146 | | { |
| | | 147 | | // Match async-method exception behavior without paying for a state machine on the hot path. |
| | | 148 | | return Task.FromException(ex); |
| | | 149 | | } |
| | | 150 | | } |
| | | 151 | | |
| | | 152 | | private static async Task AwaitSlow(ValueTask pending) |
| | | 153 | | => await pending.ConfigureAwait(false); |
| | | 154 | | |
| | | 155 | | // Internal: the durable-flow executor resolves persisted flow/input type names through the |
| | | 156 | | // same default-ALC scan + custom-resolver chain as persisted callback targets. |
| | | 157 | | [UnconditionalSuppressMessage("Trimming", "IL2026", |
| | | 158 | | Justification = "Persisted callback/flow targets are registered through APIs that root them: the expression-base |
| | | 159 | | "registration APIs annotate TService with DynamicallyAccessedMembers, WithDurableFlow<TFlow, TIn |
| | | 160 | | "flows, and the DTO-based registration APIs carry RequiresUnreferencedCode. A name that still ca |
| | | 161 | | "resolved fails closed with an actionable error and a type-resolution-failure diagnostic instead |
| | | 162 | | internal static Type? ResolveServiceType(string serviceInterfaceFullName) |
| | | 163 | | { |
| | | 164 | | // Must precede any cache consult/populate: a miss cached without the invalidation hook |
| | | 165 | | // active could outlive a later assembly load that makes the name resolvable. |
| | | 166 | | EnsureAssemblyLoadInvalidation(); |
| | | 167 | | |
| | | 168 | | if (ServiceTypes.TryGetValue(serviceInterfaceFullName, out var cached)) |
| | | 169 | | { |
| | | 170 | | return cached; |
| | | 171 | | } |
| | | 172 | | |
| | | 173 | | // Fail fast on a name that already failed a full scan: without this, every delivery naming |
| | | 174 | | // an unresolvable type (a poisoned recovery row, a renamed class) re-walks every loaded |
| | | 175 | | // assembly on every attempt. Only a CURRENT-generation entry counts — a stale stamp means |
| | | 176 | | // the miss may have raced a resolver registration or assembly load, so it rescans. |
| | | 177 | | if (UnresolvableServiceTypes.TryGetValue(serviceInterfaceFullName, out var missGeneration) |
| | | 178 | | && missGeneration == Volatile.Read(ref _unresolvableGeneration)) |
| | | 179 | | { |
| | | 180 | | AsyncResponseDiagnostics.RecordTypeResolutionFailure("service"); |
| | | 181 | | return null; |
| | | 182 | | } |
| | | 183 | | |
| | | 184 | | var generationBeforeScan = Volatile.Read(ref _unresolvableGeneration); |
| | | 185 | | |
| | | 186 | | foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) |
| | | 187 | | { |
| | | 188 | | var resolved = assembly.GetType(serviceInterfaceFullName, throwOnError: false); |
| | | 189 | | if (resolved is not null) |
| | | 190 | | { |
| | | 191 | | ServiceTypes.TryAdd(serviceInterfaceFullName, resolved); |
| | | 192 | | return resolved; |
| | | 193 | | } |
| | | 194 | | } |
| | | 195 | | |
| | | 196 | | // Opt-in fallback for callback targets loaded into a non-default AssemblyLoadContext (plugins). |
| | | 197 | | var custom = AsyncResponseTypeResolution.Resolve(serviceInterfaceFullName); |
| | | 198 | | if (custom is not null) |
| | | 199 | | { |
| | | 200 | | ServiceTypes.TryAdd(serviceInterfaceFullName, custom); |
| | | 201 | | return custom; |
| | | 202 | | } |
| | | 203 | | |
| | | 204 | | // Stamped with the generation observed BEFORE the scan: if a resolver registered while |
| | | 205 | | // this scan ran, the stamp is already stale and the entry never blocks a re-resolve. |
| | | 206 | | if (UnresolvableServiceTypes.Count < UnresolvableServiceTypeCacheCapacity) |
| | | 207 | | UnresolvableServiceTypes[serviceInterfaceFullName] = generationBeforeScan; |
| | | 208 | | |
| | | 209 | | AsyncResponseDiagnostics.RecordTypeResolutionFailure("service"); |
| | | 210 | | return null; |
| | | 211 | | } |
| | | 212 | | |
| | | 213 | | [UnconditionalSuppressMessage("Trimming", "IL2075", |
| | | 214 | | Justification = "The service type reaching this plan was rooted at registration: expression-based registration A |
| | | 215 | | "annotate TService with DynamicallyAccessedMembers(PublicMethods), and DTO-based registration ca |
| | | 216 | | "RequiresUnreferencedCode. A method removed regardless (e.g. a job enqueued by a different, non- |
| | | 217 | | "deployment) fails closed with an actionable 'no method' error.")] |
| | | 218 | | private static InvocationPlan CreateInvocationPlan(InvocationPlanKey key) |
| | | 219 | | { |
| | | 220 | | // Pick the overload by name + parameter count once, then reuse the compiled plan. |
| | | 221 | | var candidates = key.ServiceType.GetMethods(BindingFlags.Instance | BindingFlags.Public) |
| | | 222 | | .Where(m => m.Name == key.MethodName |
| | | 223 | | && m.GetParameters().Length == key.ParameterCount) |
| | | 224 | | .ToArray(); |
| | | 225 | | |
| | | 226 | | if (candidates.Length == 0) |
| | | 227 | | throw new InvalidOperationException( |
| | | 228 | | $"No method '{key.MethodName}' with {key.ParameterCount} parameter(s) on '{key.ServiceType.Name}'."); |
| | | 229 | | |
| | | 230 | | if (candidates.Length > 1) |
| | | 231 | | throw new InvalidOperationException( |
| | | 232 | | $"Method '{key.MethodName}' on '{key.ServiceType.Name}' has {candidates.Length} overloads with " + |
| | | 233 | | $"{key.ParameterCount} parameter(s); persisted callbacks cannot disambiguate overloads. " + |
| | | 234 | | "Give the callback target a unique name/arity."); |
| | | 235 | | |
| | | 236 | | var method = candidates[0]; |
| | | 237 | | var parameters = method.GetParameters(); |
| | | 238 | | var converters = new ConversionPlan[parameters.Length]; |
| | | 239 | | for (var i = 0; i < parameters.Length; i++) |
| | | 240 | | { |
| | | 241 | | var parameterType = parameters[i].ParameterType; |
| | | 242 | | if (parameterType.IsByRef) |
| | | 243 | | { |
| | | 244 | | throw new NotSupportedException( |
| | | 245 | | $"Callback method '{method.Name}' on '{key.ServiceType.Name}' uses by-ref parameter '{parameters[i]. |
| | | 246 | | } |
| | | 247 | | |
| | | 248 | | converters[i] = GetConversionPlan(parameterType); |
| | | 249 | | } |
| | | 250 | | |
| | | 251 | | if (method.ContainsGenericParameters) |
| | | 252 | | { |
| | | 253 | | throw new NotSupportedException( |
| | | 254 | | $"Callback method '{method.Name}' on '{key.ServiceType.Name}' has unbound generic parameters, which are |
| | | 255 | | } |
| | | 256 | | |
| | | 257 | | return new InvocationPlan(converters, CreateInvoker(method, parameters)); |
| | | 258 | | } |
| | | 259 | | |
| | | 260 | | private static AsyncMethodInvoker CreateInvoker(MethodInfo method, ParameterInfo[] parameters) |
| | | 261 | | { |
| | | 262 | | var service = Expression.Parameter(typeof(object), "service"); |
| | | 263 | | var args = Expression.Parameter(typeof(object?[]), "args"); |
| | | 264 | | var instance = Expression.Convert(service, method.DeclaringType!); |
| | | 265 | | var callArgs = new Expression[parameters.Length]; |
| | | 266 | | |
| | | 267 | | for (var i = 0; i < parameters.Length; i++) |
| | | 268 | | { |
| | | 269 | | var arg = Expression.ArrayIndex(args, Expression.Constant(i)); |
| | | 270 | | callArgs[i] = Expression.Convert(arg, parameters[i].ParameterType); |
| | | 271 | | } |
| | | 272 | | |
| | | 273 | | var call = Expression.Call(instance, method, callArgs); |
| | | 274 | | var body = ToValueTaskExpression(call, method.ReturnType); |
| | | 275 | | return Expression.Lambda<AsyncMethodInvoker>(body, service, args).Compile(); |
| | | 276 | | } |
| | | 277 | | |
| | | 278 | | [UnconditionalSuppressMessage("Trimming", "IL2060", |
| | | 279 | | Justification = "AwaitGenericValueTask<T> is instantiated over the callback method's ValueTask<T> result type. T |
| | | 280 | | "callback method itself was rooted at registration, and for reference-type results shared generi |
| | | 281 | | "always exists. Value-type results over a method never instantiated statically fail closed at di |
| | | 282 | | "with a clear exception rather than silently misroute.")] |
| | | 283 | | [UnconditionalSuppressMessage("AOT", "IL3050", |
| | | 284 | | Justification = "Same contract: reference-type ValueTask<T> results use shared generic code under Native AOT; th |
| | | 285 | | "exotic value-type case throws an actionable error at dispatch.")] |
| | | 286 | | private static Expression ToValueTaskExpression(MethodCallExpression call, Type returnType) |
| | | 287 | | { |
| | | 288 | | if (returnType == typeof(void)) |
| | | 289 | | return Expression.Block(call, Expression.Default(typeof(ValueTask))); |
| | | 290 | | |
| | | 291 | | if (typeof(Task).IsAssignableFrom(returnType)) |
| | | 292 | | return Expression.Call(ToValueTaskMethod, Expression.Convert(call, typeof(Task))); |
| | | 293 | | |
| | | 294 | | if (returnType == typeof(ValueTask)) |
| | | 295 | | return call; |
| | | 296 | | |
| | | 297 | | if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) |
| | | 298 | | return Expression.Call(AwaitGenericValueTaskMethod.MakeGenericMethod(returnType.GetGenericArguments()[0]), c |
| | | 299 | | |
| | | 300 | | return Expression.Block(call, Expression.Default(typeof(ValueTask))); |
| | | 301 | | } |
| | | 302 | | |
| | | 303 | | private static ValueTask ToValueTask(Task? task) |
| | | 304 | | => task is null ? default : new ValueTask(task); |
| | | 305 | | |
| | | 306 | | private static async ValueTask AwaitGenericValueTask<T>(ValueTask<T> task) |
| | | 307 | | => await task.ConfigureAwait(false); |
| | | 308 | | |
| | | 309 | | private static ConversionPlan GetConversionPlan(Type targetType) |
| | | 310 | | { |
| | | 311 | | ArgumentNullException.ThrowIfNull(targetType); |
| | | 312 | | return ConversionPlans.GetOrAdd(targetType, static type => new ConversionPlan(type)); |
| | | 313 | | } |
| | | 314 | | |
| | | 315 | | /// <summary> |
| | | 316 | | /// Given a callback template whose <c>Params</c> are <see cref="CallbackParam"/>s, produces a |
| | | 317 | | /// <see cref="ReflectionInvocationDto"/> whose <c>Params</c> are the real objects |
| | | 318 | | /// (payload, exception, correlation id, or literal values). |
| | | 319 | | /// </summary> |
| | | 320 | | public static ReflectionInvocationDto ResolveCallback( |
| | | 321 | | ReflectionCallDto template, |
| | | 322 | | object? payload, |
| | | 323 | | Exception? exception, |
| | | 324 | | string? correlationId) |
| | | 325 | | { |
| | | 326 | | var args = template.Params |
| | | 327 | | .Select(p => p.Placeholder switch |
| | | 328 | | { |
| | | 329 | | PlaceholderType.Payload => payload, |
| | | 330 | | PlaceholderType.Exception => exception, |
| | | 331 | | PlaceholderType.CorrelationId => correlationId, |
| | | 332 | | _ => p.Value |
| | | 333 | | }) |
| | | 334 | | .ToArray(); |
| | | 335 | | |
| | | 336 | | return new ReflectionInvocationDto |
| | | 337 | | { |
| | | 338 | | ServiceInterfaceFullName = template.ServiceInterfaceFullName, |
| | | 339 | | MethodName = template.MethodName, |
| | | 340 | | Params = args |
| | | 341 | | }; |
| | | 342 | | } |
| | | 343 | | |
| | | 344 | | private readonly record struct InvocationPlanKey(Type ServiceType, string MethodName, int ParameterCount); |
| | | 345 | | |
| | 3 | 346 | | private sealed class InvocationPlan(ConversionPlan[] converters, AsyncMethodInvoker invoker) |
| | | 347 | | { |
| | | 348 | | /// <summary>Runs the ConvertArguments operation.</summary> |
| | | 349 | | public object?[] ConvertArguments(object?[] args) |
| | | 350 | | { |
| | 3 | 351 | | object?[]? converted = null; |
| | | 352 | | |
| | 3 | 353 | | for (var i = 0; i < converters.Length; i++) |
| | | 354 | | { |
| | 3 | 355 | | var raw = args[i]; |
| | 3 | 356 | | var value = converters[i].Convert(raw); |
| | 3 | 357 | | if (!ReferenceEquals(value, raw)) |
| | | 358 | | { |
| | 3 | 359 | | converted ??= CopyPrefix(args, i); |
| | 3 | 360 | | converted[i] = value; |
| | | 361 | | } |
| | 3 | 362 | | else if (converted is not null) |
| | | 363 | | { |
| | 3 | 364 | | converted[i] = raw; |
| | | 365 | | } |
| | | 366 | | } |
| | | 367 | | |
| | 3 | 368 | | return converted ?? args; |
| | | 369 | | } |
| | | 370 | | |
| | | 371 | | /// <summary>Invokes the reflected operation.</summary> |
| | | 372 | | public ValueTask Invoke(object service, object?[] args) |
| | 3 | 373 | | => invoker(service, args); |
| | | 374 | | |
| | | 375 | | private static object?[] CopyPrefix(object?[] args, int length) |
| | | 376 | | { |
| | 3 | 377 | | var copy = new object?[args.Length]; |
| | 3 | 378 | | Array.Copy(args, copy, length); |
| | 3 | 379 | | return copy; |
| | | 380 | | } |
| | | 381 | | } |
| | | 382 | | |
| | | 383 | | private sealed class ConversionPlan(Type targetType) |
| | | 384 | | { |
| | | 385 | | private readonly Type? _underlyingType = Nullable.GetUnderlyingType(targetType); |
| | | 386 | | private readonly Type _conversionType = Nullable.GetUnderlyingType(targetType) ?? targetType; |
| | | 387 | | private readonly bool _isNonNullableValueType = targetType.IsValueType && Nullable.GetUnderlyingType(targetType) |
| | | 388 | | private readonly bool _isString = targetType == typeof(string); |
| | | 389 | | |
| | | 390 | | /// <summary>Converts the supplied value.</summary> |
| | | 391 | | public object? Convert(object? value) |
| | | 392 | | { |
| | | 393 | | // Handle JSON payloads (contract metadata resolved through the AOT-safe chain; loose |
| | | 394 | | // case-insensitive matching as before). |
| | | 395 | | if (value is JsonElement je) |
| | | 396 | | { |
| | | 397 | | return JsonSerializer.Deserialize(je, AsyncResponseJson.GetTypeInfo(targetType, AsyncResponseJson.CaseIn |
| | | 398 | | } |
| | | 399 | | |
| | | 400 | | // JSON in a string |
| | | 401 | | if (value is string s && !_isString) |
| | | 402 | | { |
| | | 403 | | return JsonSerializer.Deserialize(s, AsyncResponseJson.GetTypeInfo(targetType, AsyncResponseJson.CaseIns |
| | | 404 | | } |
| | | 405 | | |
| | | 406 | | // Already the correct CLR type (a boxed value also satisfies its nullable counterpart) |
| | | 407 | | if (targetType.IsInstanceOfType(value) || (_underlyingType?.IsInstanceOfType(value) ?? false)) |
| | | 408 | | { |
| | | 409 | | return value; |
| | | 410 | | } |
| | | 411 | | |
| | | 412 | | // Null handling |
| | | 413 | | if (value is null) |
| | | 414 | | { |
| | | 415 | | // The target being a non-nullable value type cannot represent null. |
| | | 416 | | if (_isNonNullableValueType) |
| | | 417 | | { |
| | | 418 | | throw new InvalidCastException($"Cannot convert null to non-nullable type {targetType}."); |
| | | 419 | | } |
| | | 420 | | |
| | | 421 | | return null; |
| | | 422 | | } |
| | | 423 | | |
| | | 424 | | // Fallback for primitives |
| | | 425 | | return System.Convert.ChangeType(value, _conversionType); |
| | | 426 | | } |
| | | 427 | | } |
| | | 428 | | } |