Skip to content

Adding a general management of json errors for some types #1127

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<SupportedTypesTestRecord>(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<SupportedTypesTestRecord>(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<SupportedTypesTestRecord>(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<SupportedTypesNonNullTestRecord>(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<SupportedTypesNonNullTestRecord>(json, _defaultSerializerOptions))
.Should().Throw<JsonException>();
json = @"
{
""short"": ""2000""
}
";
FluentActions.Invoking(() => JsonSerializer.Deserialize<SupportedTypesNonNullTestRecord>(json, _defaultSerializerOptions))
.Should().Throw<JsonException>();
json = @"
{
""float"": {""property"": ""100""}
}
";
FluentActions.Invoking(() => JsonSerializer.Deserialize<SupportedTypesNonNullTestRecord>(json, _defaultSerializerOptions))
.Should().Throw<JsonException>();
json = @"
{
""bool"": ""hello""
}
";
FluentActions.Invoking(() => JsonSerializer.Deserialize<SupportedTypesNonNullTestRecord>(json, _defaultSerializerOptions))
.Should().Throw<JsonException>();
json = @"
{
""datetime"": ""test""
}
";
FluentActions.Invoking(() => JsonSerializer.Deserialize<SupportedTypesNonNullTestRecord>(json, _defaultSerializerOptions))
.Should().Throw<JsonException>();
}
}

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; }
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@

using NetDaemon.Client.Internal.Json;

namespace NetDaemon.HassClient.Tests.Json;

public class EnsureStringConverterTests
{
/// <summary>
/// Default Json serialization options, Hass expects intended
/// </summary>
private readonly JsonSerializerOptions _defaultSerializerOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly JsonSerializerOptions _defaultSerializerOptions = DefaultSerializerOptions.DeserializationOptions;

[Fact]
public void TestConvertAValidString()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ namespace NetDaemon.Client.HomeAssistant.Extensions;
/// </summary>
public static class HassEventExtensions
{
private static readonly JsonSerializerOptions _jsonSerializerOptions = DefaultSerializerOptions.DeserializationOptions;
/// <summary>
/// Convert a HassEvent to a StateChangedEvent
/// </summary>
Expand All @@ -14,7 +15,7 @@ public static class HassEventExtensions
{
var jsonElement = hassEvent.DataElement ??
throw new NullReferenceException("DataElement cannot be empty");
return jsonElement.Deserialize<HassStateChangedEventData>();
return jsonElement.Deserialize<HassStateChangedEventData>(_jsonSerializerOptions);
}

/// <summary>
Expand All @@ -26,6 +27,6 @@ public static class HassEventExtensions
{
var jsonElement = hassEvent.DataElement ??
throw new NullReferenceException("DataElement cannot be empty");
return jsonElement.Deserialize<HassServiceEventData>();
return jsonElement.Deserialize<HassServiceEventData>(_jsonSerializerOptions);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace NetDaemon.Client.Internal.Json;

//<summary>
// Default options for serialization when serializing and deserializing json
// </summary>
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
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
namespace NetDaemon.Client.Internal.Json;

/// <summary>
/// Base class for converters that ensures the expected suported datatyps
/// </summary>
/// <remarks>
/// 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
/// </remarks>
internal abstract class EnsureExcpectedDatatypeConverterBase<T> : JsonConverter<T?>
{
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
throw new NotImplementedException();

Check warning on line 15 in src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs

View check run for this annotation

Codecov / codecov/patch

src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs#L15

Added line #L15 was not covered by tests

public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options)
{
JsonSerializer.Serialize(writer, value, typeof(T), options);
}

Check warning on line 20 in src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs

View check run for this annotation

Codecov / codecov/patch

src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs#L19-L20

Added lines #L19 - L20 were not covered by tests

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")

Check warning on line 43 in src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs

View check run for this annotation

Codecov / codecov/patch

src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs#L43

Added line #L43 was not covered by tests
};
}
catch (JsonException)

Check warning on line 46 in src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs

View check run for this annotation

Codecov / codecov/patch

src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs#L46

Added line #L46 was not covered by tests
{
// Skip the children of current token
reader.Skip();
return null;

Check warning on line 50 in src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs

View check run for this annotation

Codecov / codecov/patch

src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs#L49-L50

Added lines #L49 - L50 were not covered by tests
}
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;
}
}
}

/// <summary>
/// Converts a Json element that can be a string or returns null if it is not a string
/// </summary>
internal class EnsureStringConverter : EnsureExcpectedDatatypeConverterBase<string?>
{
public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
ReadTokenSuccessfullyOrNull(ref reader, [JsonTokenType.String, JsonTokenType.Null]) as string;
}

/// <summary>
/// Converts a Json element that can be a int or returns null if it is not a int
/// </summary>
internal class EnsureIntConverter : EnsureExcpectedDatatypeConverterBase<int?>
{
public override int? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
(int?) ReadTokenSuccessfullyOrNull(ref reader, [JsonTokenType.Number, JsonTokenType.Null]);
}

/// <summary>
/// Converts a Json element that can be a short or returns null if it is not a short
/// </summary>
internal class EnsureShortConverter : EnsureExcpectedDatatypeConverterBase<short?>
{
public override short? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
(short?) ReadTokenSuccessfullyOrNull(ref reader, [JsonTokenType.Number, JsonTokenType.Null]);
}

/// <summary>
/// Converts a Json element that can be a float or returns null if it is not afloat
/// </summary>
internal class EnsureFloatConverter : EnsureExcpectedDatatypeConverterBase<float?>
{
public override float? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
(float?) ReadTokenSuccessfullyOrNull(ref reader, [JsonTokenType.Number, JsonTokenType.Null]);
}

/// <summary>
/// Converts a Json element that can be a boolean or returns null if it is not a boolean
/// </summary>
internal class EnsureBooleanConverter : EnsureExcpectedDatatypeConverterBase<bool?>
{
public override bool? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
(bool?) ReadTokenSuccessfullyOrNull(ref reader, [JsonTokenType.True, JsonTokenType.False, JsonTokenType.Null]);
}

/// <summary>
/// Converts a Json element that can be a string or returns null if it is not a string
/// </summary>
internal class EnsureDateTimeConverter : EnsureExcpectedDatatypeConverterBase<DateTime?>
{
public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
(DateTime?) ReadTokenSuccessfullyOrNull(ref reader, [JsonTokenType.String, JsonTokenType.Null]);
}

/// <summary>
/// Return all the converters that should be used when deserializing
/// </summary>
internal static class EnsureExpectedDatatypeConverter
{
public static IList<JsonConverter> Converters() =>
[
new EnsureStringConverter(),
new EnsureIntConverter(),
new EnsureShortConverter(),
new EnsureFloatConverter(),
new EnsureBooleanConverter(),
new EnsureDateTimeConverter()
];

Check warning on line 130 in src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs

View check run for this annotation

Codecov / codecov/patch

src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs#L123-L130

Added lines #L123 - L130 were not covered by tests
}

This file was deleted.

Loading
Loading