Support optional/default params in SignalR method calls

Added support for optional/default parameters in SignalR method invocation. The hub now fills in missing arguments with default values when not provided, and throws if required parameters are missing. Added comprehensive tests for default parameter handling, a new handler method for testing, and a tag constant. Also improved code style with C# pattern matching and made UseStringCaching immutable.
This commit is contained in:
Loretta 2025-12-24 17:30:28 +01:00
parent a2f392a247
commit 9fad870960
5 changed files with 113 additions and 30 deletions

View File

@ -1,5 +1,4 @@
using System.Runtime.CompilerServices;
using AyCode.Core.Extensions;
using AyCode.Core.Serializers.Jsons;
namespace AyCode.Core.Serializers.Binaries;
@ -71,7 +70,7 @@ public sealed class AcBinarySerializerOptions : AcSerializerOptions
/// Auto-enabled when IsWasm is true, can be overridden.
/// Default: follows IsWasm setting
/// </summary>
public bool UseStringCaching { get; init; } = DetectedIsWasm;
public bool UseStringCaching { get; private init; } = DetectedIsWasm;
/// <summary>
/// Maximum string length to cache when UseStringCaching is enabled.
@ -219,13 +218,13 @@ internal static class BinaryTypeCode
/// Check if type code represents a reference (string or object).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsReference(byte code) => code == StringInterned || code == ObjectRef;
public static bool IsReference(byte code) => code is StringInterned or ObjectRef;
/// <summary>
/// Check if type code is a FixStr (short string with length encoded in type code).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsFixStr(byte code) => code >= FixStrBase && code <= FixStrMax;
public static bool IsFixStr(byte code) => code is >= FixStrBase and <= FixStrMax;
/// <summary>
/// Decode FixStr length from type code.
@ -243,7 +242,7 @@ internal static class BinaryTypeCode
/// Check if byte length can be encoded as FixStr.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool CanEncodeAsFixStr(int byteLength) => byteLength >= 0 && byteLength <= 31;
public static bool CanEncodeAsFixStr(int byteLength) => byteLength is >= 0 and <= 31;
/// <summary>
/// Check if type code is a tiny int (single byte int32 encoding).
@ -265,7 +264,7 @@ internal static class BinaryTypeCode
public static bool TryEncodeTinyInt(int value, out byte code)
{
// Range: -16 to 47 (64 values total, fitting in 192-255)
if (value >= -16 && value <= 47)
if (value is >= -16 and <= 47)
{
code = (byte)(value + 16 + Int32Tiny);
return true;

View File

@ -995,6 +995,54 @@ public abstract class SignalRClientToHubTestBase
}
#endregion
#region Default Parameter Tests
/// <summary>
/// Tests calling a method with two parameters where the second has a default value,
/// but only passing the first parameter.
/// Expected: The default value (true) should be used for the optional parameter.
/// </summary>
[TestMethod]
public async Task Post_TwoParamsWithDefault_CalledWithOneParam_UsesDefaultValue()
{
// Call with only one parameter - the second parameter should use its default value (true)
var result = await _client.PostDataAsync<int, string>(TestSignalRTags.TwoParamsWithDefault, 42);
Assert.IsNotNull(result, "Result should not be null");
Assert.AreEqual("Required: 42, Optional: True", result,
"The optional parameter should use its default value (true)");
}
/// <summary>
/// Tests calling a method with two parameters where the second has a default value,
/// passing both parameters explicitly.
/// Expected: The explicitly passed value (false) should override the default.
/// </summary>
[TestMethod]
public async Task Post_TwoParamsWithDefault_CalledWithBothParams_UsesExplicitValue()
{
// Call with both parameters - explicitly set optional to false
var result = await _client.PostAsync<string>(TestSignalRTags.TwoParamsWithDefault, [42, false]);
Assert.IsNotNull(result, "Result should not be null");
Assert.AreEqual("Required: 42, Optional: False", result,
"The optional parameter should use the explicitly passed value (false)");
}
/// <summary>
/// Tests calling with both parameters set to true explicitly.
/// </summary>
[TestMethod]
public async Task Post_TwoParamsWithDefault_CalledWithBothParamsTrue_UsesExplicitValue()
{
var result = await _client.PostAsync<string>(TestSignalRTags.TwoParamsWithDefault, [100, true]);
Assert.IsNotNull(result, "Result should not be null");
Assert.AreEqual("Required: 100, Optional: True", result);
}
#endregion
}
/// <summary>

View File

@ -538,4 +538,18 @@ public class TestSignalRService2
}
#endregion
#region Default Parameter Tests
/// <summary>
/// Method with two parameters where the second has a default value.
/// Tests if the SignalR infrastructure correctly handles optional parameters.
/// </summary>
[SignalR(TestSignalRTags.TwoParamsWithDefault)]
public string HandleTwoParamsWithDefault(int requiredParam, bool optionalParam = true)
{
return $"Required: {requiredParam}, Optional: {optionalParam}";
}
#endregion
}

View File

@ -91,4 +91,7 @@ public abstract class TestSignalRTags : AcSignalRTags
// StockTaking production bug reproduction
public const int GetStockTakings = 400;
// Default parameter tests
public const int TwoParamsWithDefault = 410;
}

View File

@ -298,10 +298,7 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration
var methodName = $"{methodsByDeclaringObject.InstanceObject.GetType().Name}.{methodInfoModel.MethodInfo.Name}";
var paramValues = DeserializeParameters(message, methodInfoModel, tagName, methodName);
if (paramValues == null)
Logger.Debug($"Found dynamic method for the tag! method: {methodName}(); {tagName}");
else
Logger.Debug($"Found dynamic method for the tag! method: {methodName}({string.Join(", ", methodInfoModel.ParamInfos.Select(x => x.Name))}); {tagName}");
Logger.Debug(paramValues == null ? $"Found dynamic method for the tag! method: {methodName}(); {tagName}" : $"Found dynamic method for the tag! method: {methodName}({string.Join(", ", methodInfoModel.ParamInfos.Select(x => x.Name))}); {tagName}");
responseData = methodInfoModel.MethodInfo.InvokeMethod(methodsByDeclaringObject.InstanceObject, paramValues);
@ -322,47 +319,69 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration
/// <summary>
/// Deserializes parameters from the message based on method signature.
/// Uses Binary serialization for message wrapper.
/// Supports optional parameters with default values.
/// </summary>
private static object[]? DeserializeParameters(byte[]? message, AcMethodInfoModel<SignalRAttribute> methodInfoModel, string tagName, string methodName)
{
if (methodInfoModel.ParamInfos is not { Length: > 0 })
var paramInfos = methodInfoModel.ParamInfos;
if (paramInfos is not { Length: > 0 })
return null;
if (message is null or { Length: 0 })
throw new ArgumentException($"Message is null or empty but method '{methodName}' requires {methodInfoModel.ParamInfos.Length} parameter(s); {tagName}");
throw new ArgumentException($"Message is null or empty but method '{methodName}' requires parameters; {tagName}");
var paramValues = new object[methodInfoModel.ParamInfos.Length];
var firstParamType = methodInfoModel.ParamInfos[0].ParameterType;
// First, try to deserialize as SignalPostJsonMessage to get raw PostDataJson
var msgBase = SignalRSerializationHelper.DeserializeFromBinary<SignalPostJsonMessage>(message);
if (msgBase?.PostDataJson == null || string.IsNullOrEmpty(msgBase.PostDataJson))
{
if (string.IsNullOrEmpty(msgBase?.PostDataJson))
throw new ArgumentException($"Failed to deserialize message for method '{methodName}'; {tagName}");
}
var json = msgBase.PostDataJson;
var paramValues = new object?[paramInfos.Length];
// Check if it's an IdMessage format (contains "Ids" property)
// IdMessage format: multiple parameters as JSON array
if (json.Contains("\"Ids\""))
{
// Parse as IdMessage - each Id is a JSON string for a parameter
var idMessage = json.JsonTo<IdMessage>();
if (idMessage?.Ids != null && idMessage.Ids.Count > 0)
var providedCount = idMessage?.Ids?.Count ?? 0;
for (var i = 0; i < paramInfos.Length; i++)
{
for (var i = 0; i < idMessage.Ids.Count && i < methodInfoModel.ParamInfos.Length; i++)
var param = paramInfos[i];
if (i < providedCount)
{
var paramType = methodInfoModel.ParamInfos[i].ParameterType;
paramValues[i] = AcJsonDeserializer.Deserialize(idMessage.Ids[i], paramType)!;
paramValues[i] = AcJsonDeserializer.Deserialize(idMessage!.Ids![i], param.ParameterType);
}
else if (param.HasDefaultValue)
{
paramValues[i] = param.DefaultValue;
}
else
{
throw new ArgumentException($"Missing required parameter '{param.Name}' for method '{methodName}'; {tagName}");
}
}
}
else
{
// Single complex object format
paramValues[0] = json.JsonTo(paramInfos[0].ParameterType);
// Fill remaining parameters with defaults
for (var i = 1; i < paramInfos.Length; i++)
{
var param = paramInfos[i];
if (param.HasDefaultValue)
{
paramValues[i] = param.DefaultValue;
}
else
{
throw new ArgumentException($"Missing required parameter '{param.Name}' for method '{methodName}'; {tagName}");
}
return paramValues;
}
}
// Single complex object - deserialize directly from PostDataJson
paramValues[0] = json.JsonTo(firstParamType)!;
return paramValues;
return paramValues!;
}
/// <summary>