diff --git a/src/Client/NetDaemon.HassClient.Tests/Json/EnsureExcpectedDatatypeConverter.cs b/src/Client/NetDaemon.HassClient.Tests/Json/EnsureExcpectedDatatypeConverter.cs new file mode 100644 index 000000000..18fd054e6 --- /dev/null +++ b/src/Client/NetDaemon.HassClient.Tests/Json/EnsureExcpectedDatatypeConverter.cs @@ -0,0 +1,146 @@ +using System.Globalization; +using NetDaemon.Client.Internal.Json; + +namespace NetDaemon.HassClient.Tests.Json; + +public class EnsureExpectedDatatypeConverterTest +{ + private readonly JsonSerializerOptions _defaultSerializerOptions = DefaultSerializerOptions.DeserializationOptions; + + [Fact] + public void TestConvertAllSupportedTypesConvertsCorrectly() + { + var json = @" + { + ""string"": ""string"", + ""int"": 10000, + ""short"": 2000, + ""float"": 1.1, + ""bool"": true, + ""datetime"": ""2019-02-16T18:11:44.183673+00:00"" + } + "; + + var record = JsonSerializer.Deserialize(json, _defaultSerializerOptions); + record!.SomeString.Should().Be("string"); + record!.SomeInt.Should().Be(10000); + record!.SomeShort.Should().Be(2000); + record!.SomeFloat.Should().Be(1.1f); + record!.SomeBool.Should().BeTrue(); + record!.SomeDateTime.Should().Be(DateTime.Parse("2019-02-16T18:11:44.183673+00:00", CultureInfo.InvariantCulture)); + } + + [Fact] + public void TestConvertAllSupportedTypesConvertsToNullWhenWrongDatatypeCorrectly() + { + var json = @" + { + ""string"": 1, + ""int"": ""10000"", + ""short"": ""2000"", + ""float"": {""property"": ""100""}, + ""bool"": ""hello"", + ""datetime"": ""test"" + } + "; + + var record = JsonSerializer.Deserialize(json, _defaultSerializerOptions); + record!.SomeString.Should().BeNull(); + record!.SomeInt.Should().BeNull(); + record!.SomeShort.Should().BeNull(); + record!.SomeFloat.Should().BeNull(); + record!.SomeBool.Should().BeNull(); + record!.SomeDateTime.Should().BeNull(); + } + + [Fact] + public void TestConvertAllSupportedTypesConvertsToNullWhenNullJsonCorrectly() + { + var json = @" + { + ""string"": null, + ""int"": null, + ""short"": null, + ""float"": null, + ""bool"": null, + ""datetime"": null + } + "; + + var record = JsonSerializer.Deserialize(json, _defaultSerializerOptions); + record!.SomeString.Should().BeNull(); + record!.SomeInt.Should().BeNull(); + record!.SomeShort.Should().BeNull(); + record!.SomeFloat.Should().BeNull(); + record!.SomeBool.Should().BeNull(); + record!.SomeDateTime.Should().BeNull(); + } + + [Fact] + public void TestConvertAllNonNullShouldThrowExcptionIfThereAreADatatypeError() + { + var json = @" + { + ""string"": 1 + } + "; + var result = JsonSerializer.Deserialize(json, _defaultSerializerOptions); + // The string can be null even if not nullable so it will not throw. + result!.SomeString.Should().BeNull(); + json = @" + { + ""int"": ""10000"" + } + "; + FluentActions.Invoking(() => JsonSerializer.Deserialize(json, _defaultSerializerOptions)) + .Should().Throw(); + json = @" + { + ""short"": ""2000"" + } + "; + FluentActions.Invoking(() => JsonSerializer.Deserialize(json, _defaultSerializerOptions)) + .Should().Throw(); + json = @" + { + ""float"": {""property"": ""100""} + } + "; + FluentActions.Invoking(() => JsonSerializer.Deserialize(json, _defaultSerializerOptions)) + .Should().Throw(); + json = @" + { + ""bool"": ""hello"" + } + "; + FluentActions.Invoking(() => JsonSerializer.Deserialize(json, _defaultSerializerOptions)) + .Should().Throw(); + json = @" + { + ""datetime"": ""test"" + } + "; + FluentActions.Invoking(() => JsonSerializer.Deserialize(json, _defaultSerializerOptions)) + .Should().Throw(); + } +} + +public record SupportedTypesTestRecord +{ + [JsonPropertyName("string")] public string? SomeString { get; init; } + [JsonPropertyName("int")] public int? SomeInt { get; init; } + [JsonPropertyName("short")] public short? SomeShort { get; init; } + [JsonPropertyName("float")] public float? SomeFloat { get; init; } + [JsonPropertyName("bool")] public bool? SomeBool { get; init; } + [JsonPropertyName("datetime")] public DateTime? SomeDateTime { get; init; } +} + +public record SupportedTypesNonNullTestRecord +{ + [JsonPropertyName("string")] public string SomeString { get; init; } = string.Empty; + [JsonPropertyName("int")] public int SomeInt { get; init; } + [JsonPropertyName("short")] public short SomeShort { get; init; } + [JsonPropertyName("float")] public float SomeFloat { get; init; } + [JsonPropertyName("bool")] public bool SomeBool { get; init; } + [JsonPropertyName("datetime")] public DateTime SomeDateTime { get; init; } +} diff --git a/src/Client/NetDaemon.HassClient.Tests/Json/EnsureStringConverterTests.cs b/src/Client/NetDaemon.HassClient.Tests/Json/EnsureStringConverterTests.cs index 18434d2b5..2cd865da3 100644 --- a/src/Client/NetDaemon.HassClient.Tests/Json/EnsureStringConverterTests.cs +++ b/src/Client/NetDaemon.HassClient.Tests/Json/EnsureStringConverterTests.cs @@ -1,4 +1,6 @@ +using NetDaemon.Client.Internal.Json; + namespace NetDaemon.HassClient.Tests.Json; public class EnsureStringConverterTests @@ -6,11 +8,7 @@ public class EnsureStringConverterTests /// /// Default Json serialization options, Hass expects intended /// - private readonly JsonSerializerOptions _defaultSerializerOptions = new() - { - WriteIndented = false, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; + private readonly JsonSerializerOptions _defaultSerializerOptions = DefaultSerializerOptions.DeserializationOptions; [Fact] public void TestConvertAValidString() diff --git a/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Extensions/HassEventExtensions.cs b/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Extensions/HassEventExtensions.cs index 849a6d6fe..08e084dc8 100644 --- a/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Extensions/HassEventExtensions.cs +++ b/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Extensions/HassEventExtensions.cs @@ -5,6 +5,7 @@ namespace NetDaemon.Client.HomeAssistant.Extensions; /// public static class HassEventExtensions { + private static readonly JsonSerializerOptions _jsonSerializerOptions = DefaultSerializerOptions.DeserializationOptions; /// /// Convert a HassEvent to a StateChangedEvent /// @@ -14,7 +15,7 @@ public static class HassEventExtensions { var jsonElement = hassEvent.DataElement ?? throw new NullReferenceException("DataElement cannot be empty"); - return jsonElement.Deserialize(); + return jsonElement.Deserialize(_jsonSerializerOptions); } /// @@ -26,6 +27,6 @@ public static class HassEventExtensions { var jsonElement = hassEvent.DataElement ?? throw new NullReferenceException("DataElement cannot be empty"); - return jsonElement.Deserialize(); + return jsonElement.Deserialize(_jsonSerializerOptions); } } diff --git a/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Model/HassDevice.cs b/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Model/HassDevice.cs index d7a7241dc..b8f964055 100644 --- a/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Model/HassDevice.cs +++ b/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Model/HassDevice.cs @@ -22,10 +22,7 @@ public record HassDevice #pragma warning disable CA1056 // It's ok for this URL to be a string [JsonPropertyName("configuration_url")] public string? ConfigurationUrl { get; init; } #pragma warning restore CA1056 - [JsonConverter(typeof(EnsureStringConverter))] [JsonPropertyName("hw_version")] public string? HardwareVersion { get; init; } - [JsonConverter(typeof(EnsureStringConverter))] [JsonPropertyName("sw_version")] public string? SoftwareVersion { get; init; } - [JsonConverter(typeof(EnsureStringConverter))] [JsonPropertyName("serial_number")] public string? SerialNumber { get; init; } } diff --git a/src/Client/NetDaemon.HassClient/Internal/Json/DefaultSerializerOptions.cs b/src/Client/NetDaemon.HassClient/Internal/Json/DefaultSerializerOptions.cs new file mode 100644 index 000000000..62f192bfd --- /dev/null +++ b/src/Client/NetDaemon.HassClient/Internal/Json/DefaultSerializerOptions.cs @@ -0,0 +1,26 @@ +namespace NetDaemon.Client.Internal.Json; + +// +// Default options for serialization when serializing and deserializing json +// +internal static class DefaultSerializerOptions +{ + public static JsonSerializerOptions DeserializationOptions => new() + { + Converters = + { + new EnsureStringConverter(), + new EnsureIntConverter(), + new EnsureShortConverter(), + new EnsureBooleanConverter(), + new EnsureFloatConverter(), + new EnsureDateTimeConverter() + } + }; + + public static JsonSerializerOptions SerializationOptions => new() + { + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; +} diff --git a/src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs b/src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs new file mode 100644 index 000000000..275c2f720 --- /dev/null +++ b/src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs @@ -0,0 +1,131 @@ +namespace NetDaemon.Client.Internal.Json; + +/// +/// Base class for converters that ensures the expected suported datatyps +/// +/// +/// This is a workaround to make the serializer to not throw exceptions when there are unexpected datatypes returning from Home Assistant json +/// This converter will only be used when deserializing json +/// +/// Note: Tried to make a even smarter generic class but could not get it to avoid recursion +/// +internal abstract class EnsureExcpectedDatatypeConverterBase : JsonConverter +{ + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotImplementedException(); + + public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value, typeof(T), options); + } + + protected static object? ReadTokenSuccessfullyOrNull(ref Utf8JsonReader reader, JsonTokenType[] tokenType) + { + if (!tokenType.Contains(reader.TokenType)) + { + // Skip the children of current token if it is not the expected one + reader.Skip(); + return null; + } + + var type = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); + + try + { + return Type.GetTypeCode(type) switch + { + TypeCode.String => reader.GetString(), + TypeCode.Int32 => reader.GetInt32(), + TypeCode.Int16 => reader.GetInt16(), + TypeCode.Boolean => reader.GetBoolean(), + TypeCode.Single => reader.GetSingle(), + TypeCode.DateTime => reader.GetDateTime(), + _ => throw new NotImplementedException($"Type {typeof(T)} with timecode {Type.GetTypeCode(type)} is not implemented") + }; + } + catch (JsonException) + { + // Skip the children of current token + reader.Skip(); + return null; + } + catch (FormatException) + { + // We are getting this exception when for example there are a format error of dates etc + // I am reluctant if this error really should just return null, codereview should discuss + // Maybe trace log the error? + reader.Skip(); + return null; + } + } +} + +/// +/// Converts a Json element that can be a string or returns null if it is not a string +/// +internal class EnsureStringConverter : EnsureExcpectedDatatypeConverterBase +{ + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + ReadTokenSuccessfullyOrNull(ref reader, [JsonTokenType.String, JsonTokenType.Null]) as string; +} + +/// +/// Converts a Json element that can be a int or returns null if it is not a int +/// +internal class EnsureIntConverter : EnsureExcpectedDatatypeConverterBase +{ + public override int? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + (int?) ReadTokenSuccessfullyOrNull(ref reader, [JsonTokenType.Number, JsonTokenType.Null]); +} + +/// +/// Converts a Json element that can be a short or returns null if it is not a short +/// +internal class EnsureShortConverter : EnsureExcpectedDatatypeConverterBase +{ + public override short? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + (short?) ReadTokenSuccessfullyOrNull(ref reader, [JsonTokenType.Number, JsonTokenType.Null]); +} + +/// +/// Converts a Json element that can be a float or returns null if it is not afloat +/// +internal class EnsureFloatConverter : EnsureExcpectedDatatypeConverterBase +{ + public override float? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + (float?) ReadTokenSuccessfullyOrNull(ref reader, [JsonTokenType.Number, JsonTokenType.Null]); +} + +/// +/// Converts a Json element that can be a boolean or returns null if it is not a boolean +/// +internal class EnsureBooleanConverter : EnsureExcpectedDatatypeConverterBase +{ + public override bool? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + (bool?) ReadTokenSuccessfullyOrNull(ref reader, [JsonTokenType.True, JsonTokenType.False, JsonTokenType.Null]); +} + +/// +/// Converts a Json element that can be a string or returns null if it is not a string +/// +internal class EnsureDateTimeConverter : EnsureExcpectedDatatypeConverterBase +{ + public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + (DateTime?) ReadTokenSuccessfullyOrNull(ref reader, [JsonTokenType.String, JsonTokenType.Null]); +} + +/// +/// Return all the converters that should be used when deserializing +/// +internal static class EnsureExpectedDatatypeConverter +{ + public static IList Converters() => + [ + new EnsureStringConverter(), + new EnsureIntConverter(), + new EnsureShortConverter(), + new EnsureFloatConverter(), + new EnsureBooleanConverter(), + new EnsureDateTimeConverter() + ]; +} diff --git a/src/Client/NetDaemon.HassClient/Internal/Json/EnsureStringConverter.cs b/src/Client/NetDaemon.HassClient/Internal/Json/EnsureStringConverter.cs deleted file mode 100644 index 1e09bd253..000000000 --- a/src/Client/NetDaemon.HassClient/Internal/Json/EnsureStringConverter.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Diagnostics; - -namespace NetDaemon.Client.Internal.Json; - -/// -/// Converts a Json element that can be a string or returns null if it is not a string -/// -class EnsureStringConverter : JsonConverter -{ - public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType == JsonTokenType.String) - { - return reader.GetString() ?? throw new UnreachableException("Token is expected to be a string"); - } - - // Skip the children of current token - reader.Skip(); - return null; - } - - public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options) => throw new NotSupportedException(); -} diff --git a/src/Client/NetDaemon.HassClient/Internal/Net/WebSocketTransportPipeline.cs b/src/Client/NetDaemon.HassClient/Internal/Net/WebSocketTransportPipeline.cs index 6367f71ae..0e58aa263 100644 --- a/src/Client/NetDaemon.HassClient/Internal/Net/WebSocketTransportPipeline.cs +++ b/src/Client/NetDaemon.HassClient/Internal/Net/WebSocketTransportPipeline.cs @@ -5,12 +5,6 @@ internal class WebSocketClientTransportPipeline(IWebSocketClient clientWebSocket /// /// Default Json serialization options, Hass expects intended /// - private readonly JsonSerializerOptions _defaultSerializerOptions = new() - { - WriteIndented = false, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - private readonly CancellationTokenSource _internalCancelSource = new(); private readonly Pipe _pipe = new(); private readonly IWebSocketClient _ws = clientWebSocket ?? throw new ArgumentNullException(nameof(clientWebSocket)); @@ -81,7 +75,7 @@ public Task SendMessageAsync(T message, CancellationToken cancelToken) where ); var result = JsonSerializer.SerializeToUtf8Bytes(message, message.GetType(), - _defaultSerializerOptions); + DefaultSerializerOptions.SerializationOptions); return _ws.SendAsync(result, WebSocketMessageType.Text, true, combinedTokenSource.Token); } @@ -104,13 +98,13 @@ private async Task ReadMessagesFromPipelineAndSerializeAsync(Cancellatio { // This is a coalesced message containing multiple messages so we need to // deserialize it as an array - return message.Deserialize() ?? throw new ApplicationException( + return message.Deserialize(DefaultSerializerOptions.DeserializationOptions) ?? throw new ApplicationException( "Deserialization of websocket returned empty result (null)"); } else { // This is normal message and we deserialize it as object - var obj = message.Deserialize() ?? throw new ApplicationException( + var obj = message.Deserialize(DefaultSerializerOptions.DeserializationOptions) ?? throw new ApplicationException( "Deserialization of websocket returned empty result (null)"); return [obj]; }